commit b7f5343a738d5c5b32c3344e836e2386ba19ba5e Author: MagentaDolphin Date: Thu Sep 3 17:07:59 2026 +0700 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d3f483 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Build output. Regenerated from source by UBT, and large - 231 MB at the time this file was +# written - so it must never enter a commit. +Binaries/ +Intermediate/ +Build/ +DerivedDataCache/ +Saved/ + +# Editor and IDE scratch +.vs/ +.idea/ +.vscode/ +*.sln +*.suo +*.VC.db +*.VC.opendb +*.xcodeproj +*.xcworkspace + +# Compiler leftovers, in case anything is ever built in place +*.obj +*.pch +*.dll +*.lib +*.exe +*.pdb diff --git a/AssetUsageAudit.uplugin b/AssetUsageAudit.uplugin new file mode 100644 index 0000000..9ef040d --- /dev/null +++ b/AssetUsageAudit.uplugin @@ -0,0 +1,37 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "0.1", + "FriendlyName": "Asset Usage Audit", + "Description": "Reports which assets are used on which levels, resolving usage transitively through Blueprints and other assets, and handling hard/soft references and One File Per Actor. Exports selected assets and usage reports.", + "Category": "Editor", + "CreatedBy": "Next-Genium", + "CanContainContent": false, + "EnabledByDefault": true, + "Modules": [ + { + "Name": "AssetUsageAuditCore", + "Type": "UncookedOnly", + "LoadingPhase": "Default", + "PlatformAllowList": [ + "Win64" + ] + }, + { + "Name": "AssetUsageAuditEditor", + "Type": "Editor", + "LoadingPhase": "PostEngineInit", + "PlatformAllowList": [ + "Win64" + ] + }, + { + "Name": "AssetUsageAuditTests", + "Type": "UncookedOnly", + "LoadingPhase": "Default", + "PlatformAllowList": [ + "Win64" + ] + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..fac5829 --- /dev/null +++ b/README.md @@ -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//`, 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`). Вынос в сабмодуль — после согласования. + +--- + +## Связанное + +- `.docs/architecture/asset-usage-audit.md` — заметка в базе знаний +- `.docs/guides/build-and-run.md` — сборка и прогон тестов +- `Plugins/AssetsCleaner` — уже включён, ищет неиспользуемые ассеты, но **не умеет привязку к уровням** diff --git a/Source/AssetUsageAuditCore/AssetUsageAuditCore.Build.cs b/Source/AssetUsageAuditCore/AssetUsageAuditCore.Build.cs new file mode 100644 index 0000000..f374895 --- /dev/null +++ b/Source/AssetUsageAuditCore/AssetUsageAuditCore.Build.cs @@ -0,0 +1,32 @@ +using UnrealBuildTool; + +public class AssetUsageAuditCore : ModuleRules +{ + public AssetUsageAuditCore(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; + + // INVARIANT: this module must stay free of UI and of the editor-only asset pipeline. + // Do NOT add: UnrealEd, AssetTools, ToolMenus, Slate, SlateCore, EditorSubsystem. + // AssetTools is editor-only transitively (it puts UnrealEd in PublicDependencyModuleNames), + // so pulling it in here would silently make the whole analysis undrivable from a commandlet. + PublicDependencyModuleNames.AddRange(new string[] + { + "Core", + "CoreUObject", + "AssetRegistry" + }); + + PrivateDependencyModuleNames.AddRange(new string[] + { + // Engine is a Runtime module and is available in commandlets, so it does not + // compromise the headless goal. It is needed for ULevel::GetExternalActorsPaths and + // ScanLevelAssets - the OFPA folder layout must never be string-built, because + // Content Bundles inject /CB//, External Data Layers inject /EDL//, and + // plugins can register further roots through delegates. + "Engine", + "Json", + "Projects" + }); + } +} diff --git a/Source/AssetUsageAuditCore/Private/AssetDependencyClosure.cpp b/Source/AssetUsageAuditCore/Private/AssetDependencyClosure.cpp new file mode 100644 index 0000000..dbfea4d --- /dev/null +++ b/Source/AssetUsageAuditCore/Private/AssetDependencyClosure.cpp @@ -0,0 +1,297 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetDependencyClosure.h" + +#include "AssetUsageAuditCoreModule.h" +#include "AssetUsageAuditTypes.h" +#include "AssetUsagePaths.h" +#include "AssetRegistry/AssetData.h" +#include "AssetRegistry/IAssetRegistry.h" +#include "Engine/World.h" +#include "Misc/PackageName.h" + +namespace AssetDependencyClosure +{ + FString FStats::Summarise() const + { + TArray Parts; + + Parts.Add(FString::Printf(TEXT("%d seed(s) -> %d package(s)"), SeedCount, TotalCount)); + + if (SkippedScript > 0) + { + Parts.Add(FString::Printf(TEXT("%d script"), SkippedScript)); + } + + if (SkippedEngine > 0) + { + Parts.Add(FString::Printf(TEXT("%d engine/temp"), SkippedEngine)); + } + + if (SkippedExcluded > 0) + { + Parts.Add(FString::Printf(TEXT("%d excluded by path"), SkippedExcluded)); + } + + if (ForeignLevelsSkipped > 0) + { + Parts.Add(FString::Printf(TEXT("%d other map(s) not followed"), ForeignLevelsSkipped)); + } + + Parts.Add(FString::Printf(TEXT("deepest hop %d"), DeepestHop)); + + if (bStoppedEarly) + { + Parts.Add(TEXT("stopped early")); + } + + return FString::Join(Parts, TEXT(", ")); + } + + TArray GatherPerSeed(IAssetRegistry& Registry, const TArray& Seeds, const FOptions& Options, FStats& OutStats) + { + OutStats = FStats(); + + TArray Result; + Result.Reserve(Seeds.Num()); + + // Deduplicate seeds first: the same asset ticked twice would otherwise produce two identical + // folders, and the second would be silently renamed by the collision policy. + TSet SeenSeeds; + SeenSeeds.Reserve(Seeds.Num()); + + for (int32 Index = 0; Index < Seeds.Num(); ++Index) + { + const FName Seed = Seeds[Index]; + + if (Seed.IsNone() || SeenSeeds.Contains(Seed)) + { + continue; + } + + SeenSeeds.Add(Seed); + + // Progress is reported per seed here, not per package: a caller showing a bar wants it + // to advance once per ticked asset, and the inner traversals have wildly uneven sizes. + if (Options.OnProgress && !Options.OnProgress(Index, Seeds.Num())) + { + OutStats.bStoppedEarly = true; + break; + } + + // Each seed gets its own traversal and its own visited set. That is the whole point: + // sharing one set would give the first seed every shared dependency and leave the rest + // with folders that are missing their textures. + FOptions SeedOptions = Options; + SeedOptions.OnProgress = nullptr; + + FStats SeedStats; + FSeedClosure& Closure = Result.AddDefaulted_GetRef(); + Closure.Seed = Seed; + Closure.Packages = Gather(Registry, { Seed }, SeedOptions, SeedStats); + + OutStats.SkippedScript += SeedStats.SkippedScript; + OutStats.SkippedEngine += SeedStats.SkippedEngine; + OutStats.SkippedExcluded += SeedStats.SkippedExcluded; + OutStats.ForeignLevelsSkipped += SeedStats.ForeignLevelsSkipped; + OutStats.DeepestHop = FMath::Max(OutStats.DeepestHop, SeedStats.DeepestHop); + OutStats.TotalCount += SeedStats.TotalCount; + ++OutStats.SeedCount; + } + + UE_LOG(LogAssetUsageAudit, Log, + TEXT("Per-seed closure: %d seed(s), %d package placement(s) counting duplicates."), + OutStats.SeedCount, OutStats.TotalCount); + + return Result; + } + + TArray Gather(IAssetRegistry& Registry, const TArray& Seeds, const FOptions& Options, FStats& OutStats) + { + OutStats = FStats(); + + TArray Result; + TSet Visited; + + // Hop count per queued entry, kept alongside the queue rather than in a map: the queue is + // the only place an index is needed and a parallel array is one allocation instead of N. + TArray Hops; + + Result.Reserve(Seeds.Num() * 4); + Visited.Reserve(Seeds.Num() * 4); + + const auto ShouldKeep = [&Options, &OutStats](FName PackageName) -> bool + { + if (AssetUsagePaths::IsScriptPackage(PackageName)) + { + ++OutStats.SkippedScript; + return false; + } + + const FString PackageStr = PackageName.ToString(); + + if (!Options.bIncludeEnginePackages && AssetUsagePaths::IsEngineOrTempPackage(PackageStr)) + { + ++OutStats.SkippedEngine; + return false; + } + + if (AssetUsagePaths::IsPathExcluded(PackageName, Options.ExcludePackagePaths)) + { + ++OutStats.SkippedExcluded; + return false; + } + + return true; + }; + + // Which packages are maps, answered once each. The registry call allocates an FAssetData + // array, and the same package is reached from many referencers, so the cache turns tens of + // thousands of lookups into one per distinct package. + TMap LevelCache; + + const auto IsLevelPackage = [&Registry, &LevelCache](FName PackageName) -> bool + { + if (const bool* Cached = LevelCache.Find(PackageName)) + { + return *Cached; + } + + TArray Assets; + Registry.GetAssetsByPackageName(PackageName, Assets, /*bIncludeOnlyOnDiskAssets=*/true); + + bool bIsLevel = false; + for (const FAssetData& AssetData : Assets) + { + if (AssetData.AssetClassPath == UWorld::StaticClass()->GetClassPathName()) + { + bIsLevel = true; + break; + } + } + + LevelCache.Add(PackageName, bIsLevel); + return bIsLevel; + }; + + // Seeds are admitted without the path filters. Someone who ticked a row is entitled to the + // file for that row even if it sits under an excluded folder; the filters bound what the + // traversal drags in behind it, which is a different question. + // + // A seed that is itself a map is a level export: the user asked for that location, so its + // contents are the point. Foreign maps found later are a different matter entirely. + // Levels the closure has admitted, seeds first. A level may only be crossed into from + // another level or from an external actor package, so this set has to grow as structural + // crossings are accepted - otherwise a sublevel of a sublevel would be refused. + TSet LevelsInResult; + + for (FName Seed : Seeds) + { + if (Seed.IsNone() || Visited.Contains(Seed)) + { + continue; + } + + if (!FPackageName::IsValidLongPackageName(Seed.ToString())) + { + continue; + } + + Visited.Add(Seed); + Result.Add(Seed); + Hops.Add(0); + ++OutStats.SeedCount; + + if (IsLevelPackage(Seed)) + { + LevelsInResult.Add(Seed); + } + } + + TArray ScratchDependencies; + + for (int32 Head = 0; Head < Result.Num(); ++Head) + { + if (Options.OnProgress && !Options.OnProgress(Head, Result.Num())) + { + OutStats.bStoppedEarly = true; + break; + } + + const int32 CurrentHop = Hops[Head]; + + if (Options.MaxDepth > 0 && CurrentHop >= Options.MaxDepth) + { + continue; + } + + ScratchDependencies.Reset(); + + // Package category, no requirements. Never Hard: in EDependencyQuery Soft is defined as + // NotHard, so Hard|Soft matches nothing, and a Hard-only query drops every soft edge - + // including every TSoftObjectPtr a Blueprint resolves at runtime and every OFPA package. + Registry.GetDependencies( + Result[Head], + ScratchDependencies, + AssetUsageAudit::MakeTraversalCategory(), + AssetUsageAudit::MakeTraversalQuery()); + + for (FName Dependency : ScratchDependencies) + { + if (Dependency.IsNone() || Visited.Contains(Dependency)) + { + continue; + } + + // Marked visited even when dropped, so a package excluded by path is not re-tested + // once per referencer. On a mesh library that is thousands of redundant string + // comparisons. + Visited.Add(Dependency); + + // The map boundary. Reaching another World is only legitimate when the reference + // comes from a level or from one of its external actor packages - the shape a + // streaming sublevel or a Level Instance actually has. A World named by an ordinary + // asset is a different location that happens to be mentioned, and following it + // exports someone else's map: measured on WP_Main, the chain + // BP_FirstPersonGameMode -> PDA_MenuSystemConfig -> L_MainLevel accounts for 9994 + // packages. FLevelUsageResolver applies the same rule; the two must not disagree + // about the same graph. + if (IsLevelPackage(Dependency)) + { + const FName Source = Result[Head]; + const bool bStructuralCrossing = + LevelsInResult.Contains(Source) || AssetUsagePaths::IsExternalPackage(Source); + + if (!bStructuralCrossing) + { + ++OutStats.ForeignLevelsSkipped; + + UE_LOG(LogAssetUsageAudit, Verbose, + TEXT("Not following '%s': reached from '%s', which is not a level or an external actor package."), + *Dependency.ToString(), *Source.ToString()); + + continue; + } + + LevelsInResult.Add(Dependency); + } + + if (!ShouldKeep(Dependency)) + { + continue; + } + + Result.Add(Dependency); + Hops.Add(CurrentHop + 1); + + OutStats.DeepestHop = FMath::Max(OutStats.DeepestHop, CurrentHop + 1); + } + } + + OutStats.TotalCount = Result.Num(); + + UE_LOG(LogAssetUsageAudit, Log, TEXT("Dependency closure: %s"), *OutStats.Summarise()); + + return Result; + } +} diff --git a/Source/AssetUsageAuditCore/Private/AssetExportLayout.cpp b/Source/AssetUsageAuditCore/Private/AssetExportLayout.cpp new file mode 100644 index 0000000..06efa30 --- /dev/null +++ b/Source/AssetUsageAuditCore/Private/AssetExportLayout.cpp @@ -0,0 +1,239 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetExportLayout.h" + +#include "AssetTypeFilter.h" +#include "AssetUsageAuditCoreModule.h" +#include "AssetRegistry/AssetData.h" +#include "AssetRegistry/IAssetRegistry.h" +#include "Misc/PackageName.h" +#include "Misc/PathViews.h" + +namespace AssetExportLayout +{ + namespace + { + /** Folder for anything no preset covers. Never empty - see the header. */ + const TCHAR* OtherFolderName = TEXT("Other"); + + /** + * Make a package name safe to use as a folder name. + * + * Asset names are already constrained by Unreal, but a folder name is not the same thing as + * an asset name and the target may be any filesystem. FPaths::MakeValidFileName replaces + * what Windows rejects; the empty check catches a name that was nothing but invalid + * characters, which would otherwise produce a group writing into the export root. + */ + FString MakeFolderName(FName PackageName) + { + FString Name = FPackageName::GetShortName(PackageName.ToString()); + Name = FPaths::MakeValidFileName(Name, TEXT('_')); + + return Name.IsEmpty() ? FString(TEXT("Asset")) : Name; + } + } + + FString FStats::Summarise() const + { + TArray Parts; + + Parts.Add(FString::Printf(TEXT("%d group(s)"), GroupCount)); + Parts.Add(FString::Printf(TEXT("%d distinct package(s)"), DistinctPackages)); + + // Always stated, even when equal to DistinctPackages: under FolderPerAsset the gap between + // the two is the surprise, and a reader should not have to infer that they match. + Parts.Add(FString::Printf(TEXT("%d file placement(s)"), FilePlacements)); + + if (RenamedFolders > 0) + { + Parts.Add(FString::Printf(TEXT("%d folder name(s) made unique"), RenamedFolders)); + } + + return FString::Join(Parts, TEXT(", ")); + } + + TMap BuildTypeFolderMap(IAssetRegistry& Registry) + { + TMap FolderByClass; + + for (EAssetTypePreset Preset : GetAllAssetTypePresets()) + { + const TArray Seeds = GetPresetClassPaths(Preset); + if (Seeds.IsEmpty()) + { + continue; + } + + const FString FolderName = LexToString(Preset); + + TSet Derived; + Registry.GetDerivedClassNames(Seeds, TSet(), Derived); + + // Seeds added explicitly: GetDerivedClassNames returns strict descendants in some + // builds and includes the seeds in others. The same reason FAssetTypeFilter::Compile + // does it, and the same consequence if it is forgotten - Material would match every + // material instance but not Material itself. + for (const FTopLevelAssetPath& ClassPath : Seeds) + { + FolderByClass.FindOrAdd(ClassPath, FolderName); + } + + for (const FTopLevelAssetPath& ClassPath : Derived) + { + // FindOrAdd, not Add: presets overlap (a Niagara system is also a UObject subclass + // reachable from more than one seed) and the first preset to claim a class wins. + // Overwriting would make the folder depend on preset iteration order. + FolderByClass.FindOrAdd(ClassPath, FolderName); + } + } + + return FolderByClass; + } + + FString TypeFolderForPackage(IAssetRegistry& Registry, FName PackageName, const TMap& FolderByClass) + { + TArray Assets; + Registry.GetAssetsByPackageName(PackageName, Assets, /*bIncludeOnlyOnDiskAssets=*/true); + + for (const FAssetData& AssetData : Assets) + { + if (const FString* Folder = FolderByClass.Find(AssetData.AssetClassPath)) + { + return *Folder; + } + } + + return OtherFolderName; + } + + TArray BuildGroups(IAssetRegistry& Registry, const TArray& SeedClosures, const FOptions& Options, FStats& OutStats) + { + OutStats = FStats(); + + TArray Groups; + TSet DistinctPackages; + + // Flat and MirrorTree do not care which seed pulled a package in, so they collapse to one + // group and let the exporter's own bFlatten decide the path. FolderPerAsset is the only + // layout that needs the per-seed structure, and it is the only one that pays for it. + if (Options.Layout != EExportLayout::FolderPerAsset) + { + FGroup& Group = Groups.AddDefaulted_GetRef(); + + for (const AssetDependencyClosure::FSeedClosure& Closure : SeedClosures) + { + for (FName Package : Closure.Packages) + { + if (!DistinctPackages.Contains(Package)) + { + DistinctPackages.Add(Package); + Group.Packages.Add(Package); + } + } + } + + OutStats.GroupCount = Groups.Num(); + OutStats.DistinctPackages = DistinctPackages.Num(); + OutStats.FilePlacements = Group.Packages.Num(); + + UE_LOG(LogAssetUsageAudit, Log, TEXT("Export layout %s: %s"), LexToString(Options.Layout), *OutStats.Summarise()); + + return Groups; + } + + // Folder names must be unique across the export, and asset names are not: SM_Rock can exist + // in three different folders in /Game. Two seeds sharing a short name would otherwise write + // into one folder and interleave their dependencies. + TSet UsedFolderNames; + + const TMap FolderByClass = Options.bGroupDependenciesByType + ? BuildTypeFolderMap(Registry) + : TMap(); + + for (const AssetDependencyClosure::FSeedClosure& Closure : SeedClosures) + { + if (Closure.Packages.IsEmpty()) + { + continue; + } + + FString FolderName = MakeFolderName(Closure.Seed); + + if (UsedFolderNames.Contains(FolderName)) + { + const FString Base = FolderName; + int32 Suffix = 1; + + while (UsedFolderNames.Contains(FolderName)) + { + FolderName = FString::Printf(TEXT("%s_%d"), *Base, Suffix++); + } + + ++OutStats.RenamedFolders; + } + + UsedFolderNames.Add(FolderName); + + // The seed sits at the root of its own folder; dependencies go beside it, or into + // per-type subfolders when asked. Grouping the seed too would bury the subject of the + // folder among the things it needs. + FGroup& SeedGroup = Groups.AddDefaulted_GetRef(); + SeedGroup.RelativeDir = FolderName; + SeedGroup.Packages.Add(Closure.Seed); + + DistinctPackages.Add(Closure.Seed); + ++OutStats.FilePlacements; + + // One group per type subfolder, built lazily so a folder is only created for a type + // that actually has files in it. + TMap GroupIndexByDir; + + for (FName Package : Closure.Packages) + { + if (Package == Closure.Seed) + { + continue; + } + + DistinctPackages.Add(Package); + ++OutStats.FilePlacements; + + FString RelativeDir = FolderName; + + if (Options.bGroupDependenciesByType) + { + RelativeDir = FPaths::Combine(FolderName, TypeFolderForPackage(Registry, Package, FolderByClass)); + } + + if (int32* ExistingIndex = GroupIndexByDir.Find(RelativeDir)) + { + Groups[*ExistingIndex].Packages.Add(Package); + continue; + } + + // The dependency group for the seed's own folder is the one already created above; + // reuse it rather than adding a second group with the same RelativeDir, which the + // exporter would treat as a separate destination. + if (RelativeDir == FolderName) + { + SeedGroup.Packages.Add(Package); + GroupIndexByDir.Add(RelativeDir, Groups.Num() - 1); + continue; + } + + FGroup& TypeGroup = Groups.AddDefaulted_GetRef(); + TypeGroup.RelativeDir = RelativeDir; + TypeGroup.Packages.Add(Package); + + GroupIndexByDir.Add(RelativeDir, Groups.Num() - 1); + } + } + + OutStats.GroupCount = Groups.Num(); + OutStats.DistinctPackages = DistinctPackages.Num(); + + UE_LOG(LogAssetUsageAudit, Log, TEXT("Export layout %s: %s"), LexToString(Options.Layout), *OutStats.Summarise()); + + return Groups; + } +} diff --git a/Source/AssetUsageAuditCore/Private/AssetExportManifest.cpp b/Source/AssetUsageAuditCore/Private/AssetExportManifest.cpp new file mode 100644 index 0000000..f0c6183 --- /dev/null +++ b/Source/AssetUsageAuditCore/Private/AssetExportManifest.cpp @@ -0,0 +1,171 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetExportManifest.h" + +#include "AssetUsageAuditCoreModule.h" +#include "AssetRegistry/IAssetRegistry.h" +#include "Dom/JsonObject.h" +#include "Misc/App.h" +#include "Misc/EngineVersion.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" +#include "Serialization/JsonSerializer.h" +#include "Serialization/JsonWriter.h" + +namespace AssetExportManifest +{ + FResult Write(IAssetRegistry& Registry, const TArray& WrittenFiles, const FOptions& Options) + { + FResult Result; + + if (Options.TargetDirectory.IsEmpty()) + { + Result.ErrorMessage = TEXT("No target directory was given."); + UE_LOG(LogAssetUsageAudit, Error, TEXT("Manifest not written: no target directory.")); + return Result; + } + + if (WrittenFiles.IsEmpty()) + { + // Deliberately an error. The usual cause is a caller that forgot bRecordWrittenFiles, + // and a manifest listing nothing beside a folder full of assets would be taken at face + // value by whatever reads it next. + Result.ErrorMessage = TEXT("No written files were recorded; the exporter must run with bRecordWrittenFiles."); + UE_LOG(LogAssetUsageAudit, Error, + TEXT("Manifest not written: the exporter recorded no files. Set FOptions::bRecordWrittenFiles.")); + return Result; + } + + if (Options.Layout == EExportLayout::Migrate) + { + Result.ErrorMessage = TEXT("Migrate preserves package paths itself; a manifest would be misleading."); + UE_LOG(LogAssetUsageAudit, Warning, + TEXT("Manifest not written: the Migrate layout keeps references intact, so there is nothing to restore.")); + return Result; + } + + // Where each package ended up, so dependency entries can point at a file in this folder + // rather than at a package path the reader has no way to resolve. + TMap PathByPackage; + PathByPackage.Reserve(WrittenFiles.Num()); + + for (const FAssetUsageExporter::FWrittenFile& Written : WrittenFiles) + { + // First writer wins. Under FolderPerAsset the same package is written into several + // folders; the per-entry "file" field below is the authoritative one for each copy, and + // this map only serves the "present in this export" test. + if (!PathByPackage.Contains(Written.PackageName)) + { + PathByPackage.Add(Written.PackageName, Written.RelativePath); + } + } + + TSharedRef Root = MakeShared(); + + Root->SetNumberField(TEXT("schemaVersion"), SchemaVersion); + Root->SetStringField(TEXT("tool"), TEXT("AssetUsageAudit")); + Root->SetStringField(TEXT("generatedAt"), FDateTime::UtcNow().ToIso8601()); + Root->SetStringField(TEXT("project"), FApp::GetProjectName()); + Root->SetStringField(TEXT("engineVersion"), FEngineVersion::Current().ToString()); + Root->SetStringField(TEXT("layout"), LexToString(Options.Layout)); + Root->SetStringField(TEXT("collisionPolicy"), + Options.CollisionPolicy == EExportCollisionPolicy::Overwrite ? TEXT("Overwrite") : TEXT("Index")); + + Root->SetStringField(TEXT("note"), + TEXT("Paths are relative to this file's folder. 'package' is the original Unreal package name; ") + TEXT("restoring an asset means putting its file back at that package path. 'dependencies' lists ") + TEXT("the packages each asset referenced at export time, whether or not they are in this folder.")); + + TArray> Entries; + Entries.Reserve(WrittenFiles.Num()); + + TArray ScratchDependencies; + + for (const FAssetUsageExporter::FWrittenFile& Written : WrittenFiles) + { + TSharedRef Entry = MakeShared(); + + Entry->SetStringField(TEXT("package"), Written.PackageName.ToString()); + Entry->SetStringField(TEXT("file"), Written.RelativePath); + Entry->SetBoolField(TEXT("seed"), Options.SeedPackages.Contains(Written.PackageName)); + + // Asset name and class, when the registry knows the package. A package can hold several + // assets; the first is representative and the class is only a hint for a reader. + TArray AssetsInPackage; + Registry.GetAssetsByPackageName(Written.PackageName, AssetsInPackage, /*bIncludeOnlyOnDiskAssets=*/true); + + if (!AssetsInPackage.IsEmpty()) + { + Entry->SetStringField(TEXT("asset"), AssetsInPackage[0].AssetName.ToString()); + Entry->SetStringField(TEXT("class"), AssetsInPackage[0].AssetClassPath.ToString()); + } + + // Direct dependencies only. The full closure is recoverable by walking these entries, + // and storing it per asset would repeat most of the file for no extra information. + ScratchDependencies.Reset(); + Registry.GetDependencies( + Written.PackageName, + ScratchDependencies, + AssetUsageAudit::MakeTraversalCategory(), + AssetUsageAudit::MakeTraversalQuery()); + + TArray> Dependencies; + Dependencies.Reserve(ScratchDependencies.Num()); + + for (FName Dependency : ScratchDependencies) + { + if (Dependency.IsNone()) + { + continue; + } + + TSharedRef DependencyEntry = MakeShared(); + DependencyEntry->SetStringField(TEXT("package"), Dependency.ToString()); + + // Whether the dependency travelled with the export. A reader needs this to tell + // "restore this link from a file here" from "this link points outside the folder". + if (const FString* DependencyPath = PathByPackage.Find(Dependency)) + { + DependencyEntry->SetStringField(TEXT("file"), *DependencyPath); + } + + Dependencies.Add(MakeShared(DependencyEntry)); + ++Result.EdgesRecorded; + } + + Entry->SetArrayField(TEXT("dependencies"), Dependencies); + Entries.Add(MakeShared(Entry)); + } + + Root->SetArrayField(TEXT("assets"), Entries); + + FString Output; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&Output); + + if (!FJsonSerializer::Serialize(Root, Writer)) + { + Result.ErrorMessage = TEXT("Could not serialise the manifest."); + UE_LOG(LogAssetUsageAudit, Error, TEXT("Manifest not written: serialisation failed.")); + return Result; + } + + const FString FilePath = FPaths::Combine(Options.TargetDirectory, FileName); + + if (!FFileHelper::SaveStringToFile(Output, *FilePath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) + { + Result.ErrorMessage = FString::Printf(TEXT("Could not write '%s'."), *FilePath); + UE_LOG(LogAssetUsageAudit, Error, TEXT("Manifest not written: could not save '%s'."), *FilePath); + return Result; + } + + Result.bSuccess = true; + Result.FilePath = FilePath; + Result.EntriesWritten = WrittenFiles.Num(); + + UE_LOG(LogAssetUsageAudit, Log, + TEXT("Manifest written: %d entries, %d dependency edges, '%s'."), + Result.EntriesWritten, Result.EdgesRecorded, *FilePath); + + return Result; + } +} diff --git a/Source/AssetUsageAuditCore/Private/AssetExportNaming.cpp b/Source/AssetUsageAuditCore/Private/AssetExportNaming.cpp new file mode 100644 index 0000000..f650997 --- /dev/null +++ b/Source/AssetUsageAuditCore/Private/AssetExportNaming.cpp @@ -0,0 +1,132 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetExportNaming.h" +#include "AssetUsageAuditCoreModule.h" + +namespace AssetExportNaming +{ + namespace + { + /** Guard against a pathological folder where every candidate is taken. */ + constexpr int32 MaxCollisionAttempts = 100000; + + bool IsAllDigits(const FString& Value) + { + if (Value.IsEmpty()) + { + return false; + } + + for (const TCHAR Char : Value) + { + if (!FChar::IsDigit(Char)) + { + return false; + } + } + + return true; + } + } + + void SplitTrailingIndex(const FString& BaseName, FString& OutStem, int32& OutIndex, int32& OutPadWidth) + { + OutStem = BaseName; + OutIndex = INDEX_NONE; + OutPadWidth = 0; + + int32 SeparatorPos = INDEX_NONE; + if (!BaseName.FindLastChar(TEXT('_'), SeparatorPos)) + { + return; + } + + // A leading underscore is part of the name, not a separator: "_1" has no stem. + if (SeparatorPos <= 0) + { + return; + } + + const FString Suffix = BaseName.Mid(SeparatorPos + 1); + + // "Foo_" has an empty suffix, "SM_Rock_02_v3" has a non-numeric one. Neither is an index. + if (!IsAllDigits(Suffix)) + { + return; + } + + OutStem = BaseName.Left(SeparatorPos); + OutIndex = FCString::Atoi(*Suffix); + OutPadWidth = Suffix.Len(); + } + + FString ComposeIndexedName(const FString& Stem, int32 Index, int32 PadWidth) + { + if (Index == INDEX_NONE) + { + return Stem; + } + + FString Digits = FString::FromInt(Index); + + // Preserve the original zero padding, but never truncate: Foo_09 -> Foo_10, Foo_99 -> Foo_100. + while (Digits.Len() < PadWidth) + { + Digits.InsertAt(0, TEXT('0')); + } + + return FString::Printf(TEXT("%s_%s"), *Stem, *Digits); + } + + FString ResolveCollision(const FString& DesiredFileName, EExportCollisionPolicy Policy, FExistsPredicate Exists) + { + if (Policy == EExportCollisionPolicy::Overwrite) + { + return DesiredFileName; + } + + if (!Exists(DesiredFileName)) + { + return DesiredFileName; + } + + // Split off the extension so the index lands before it: SM_Rock.uasset -> SM_Rock_1.uasset. + // Note this splits on the LAST dot, so a name like "M_Glass.v2.uasset" keeps "M_Glass.v2" as the base. + FString BaseName = DesiredFileName; + FString Extension; + + int32 DotPos = INDEX_NONE; + if (DesiredFileName.FindLastChar(TEXT('.'), DotPos) && DotPos > 0) + { + BaseName = DesiredFileName.Left(DotPos); + Extension = DesiredFileName.Mid(DotPos); // includes the dot + } + + FString Stem; + int32 Index = INDEX_NONE; + int32 PadWidth = 0; + SplitTrailingIndex(BaseName, Stem, Index, PadWidth); + + // No trailing index yet: start at _1. Otherwise increment what is already there, + // rather than appending a second suffix and producing Foo_1_1. + int32 NextIndex = (Index == INDEX_NONE) ? 1 : Index + 1; + const int32 EffectivePadWidth = (Index == INDEX_NONE) ? 1 : PadWidth; + + for (int32 Attempt = 0; Attempt < MaxCollisionAttempts; ++Attempt, ++NextIndex) + { + const FString Candidate = ComposeIndexedName(Stem, NextIndex, EffectivePadWidth) + Extension; + if (!Exists(Candidate)) + { + return Candidate; + } + } + + // Every candidate taken. Returning the desired name would silently overwrite, which is the + // one outcome the Index policy exists to prevent - so fail loudly instead. + UE_LOG(LogAssetUsageAudit, Error, + TEXT("Could not find a free filename for '%s' after %d attempts. Export of this asset will be skipped."), + *DesiredFileName, MaxCollisionAttempts); + + return FString(); + } +} diff --git a/Source/AssetUsageAuditCore/Private/AssetTypeFilter.cpp b/Source/AssetUsageAuditCore/Private/AssetTypeFilter.cpp new file mode 100644 index 0000000..59c7626 --- /dev/null +++ b/Source/AssetUsageAuditCore/Private/AssetTypeFilter.cpp @@ -0,0 +1,216 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetTypeFilter.h" + +#include "AssetUsageAuditCoreModule.h" +#include "AssetUsageGraph.h" +#include "AssetRegistry/IAssetRegistry.h" +#include "UObject/Class.h" +#include "UObject/UObjectGlobals.h" + +const TCHAR* LexToString(EAssetTypePreset Preset) +{ + switch (Preset) + { + case EAssetTypePreset::StaticMesh: return TEXT("StaticMesh"); + case EAssetTypePreset::SkeletalMesh: return TEXT("SkeletalMesh"); + case EAssetTypePreset::Material: return TEXT("Material"); + case EAssetTypePreset::Texture: return TEXT("Texture"); + case EAssetTypePreset::VFX: return TEXT("VFX"); + case EAssetTypePreset::Sound: return TEXT("Sound"); + case EAssetTypePreset::Blueprint: return TEXT("Blueprint"); + case EAssetTypePreset::Level: return TEXT("Level"); + case EAssetTypePreset::DataAsset: return TEXT("DataAsset"); + case EAssetTypePreset::Animation: return TEXT("Animation"); + } + + return TEXT("Unknown"); +} + +TArray GetAllAssetTypePresets() +{ + return { + EAssetTypePreset::StaticMesh, + EAssetTypePreset::SkeletalMesh, + EAssetTypePreset::Material, + EAssetTypePreset::Texture, + EAssetTypePreset::VFX, + EAssetTypePreset::Sound, + EAssetTypePreset::Blueprint, + EAssetTypePreset::Level, + EAssetTypePreset::DataAsset, + EAssetTypePreset::Animation + }; +} + +TArray GetPresetClassPaths(EAssetTypePreset Preset) +{ + const auto Path = [](const TCHAR* InPath) + { + return FTopLevelAssetPath(InPath); + }; + + switch (Preset) + { + case EAssetTypePreset::StaticMesh: + return { Path(TEXT("/Script/Engine.StaticMesh")) }; + + case EAssetTypePreset::SkeletalMesh: + return { Path(TEXT("/Script/Engine.SkeletalMesh")) }; + + case EAssetTypePreset::Material: + // MaterialInterface covers Material, MaterialInstanceConstant and MaterialInstanceDynamic; + // material functions are a separate hierarchy and must be named explicitly. + return { + Path(TEXT("/Script/Engine.MaterialInterface")), + Path(TEXT("/Script/Engine.MaterialFunctionInterface")) + }; + + case EAssetTypePreset::Texture: + return { Path(TEXT("/Script/Engine.Texture")) }; + + case EAssetTypePreset::VFX: + // Niagara classes live in the Niagara module. They resolve only when the plugin is + // enabled; unresolved entries are reported rather than silently dropped. + return { + Path(TEXT("/Script/Niagara.NiagaraSystem")), + Path(TEXT("/Script/Niagara.NiagaraEmitter")), + Path(TEXT("/Script/Engine.ParticleSystem")) + }; + + case EAssetTypePreset::Sound: + // FMOD is this project's audio system. FMOD assets are included for completeness, but + // FMOD resolves events by string path outside the UObject graph, so their usage verdict + // is unreliable by construction - the report marks them Unknown. + return { + Path(TEXT("/Script/Engine.SoundBase")), + Path(TEXT("/Script/FMODStudio.FMODAsset")) + }; + + case EAssetTypePreset::Blueprint: + return { Path(TEXT("/Script/Engine.Blueprint")) }; + + case EAssetTypePreset::Level: + return { Path(TEXT("/Script/Engine.World")) }; + + case EAssetTypePreset::DataAsset: + return { + Path(TEXT("/Script/Engine.DataAsset")), + Path(TEXT("/Script/Engine.DataTable")) + }; + + case EAssetTypePreset::Animation: + return { + Path(TEXT("/Script/Engine.AnimationAsset")), + Path(TEXT("/Script/Engine.AnimBlueprint")) + }; + } + + return {}; +} + +void FAssetTypeFilter::AddPreset(EAssetTypePreset Preset) +{ + for (const FTopLevelAssetPath& ClassPath : GetPresetClassPaths(Preset)) + { + RequestedClasses.AddUnique(ClassPath); + } + + RequestedDescriptions.AddUnique(LexToString(Preset)); + bCompiled = false; +} + +void FAssetTypeFilter::AddRawClass(const FString& ClassPathOrName) +{ + const FString Trimmed = ClassPathOrName.TrimStartAndEnd(); + if (Trimmed.IsEmpty()) + { + return; + } + + FTopLevelAssetPath Resolved; + + if (Trimmed.StartsWith(TEXT("/"))) + { + Resolved.TrySetPath(Trimmed); + } + else + { + // A short name like "StaticMesh". TryFindType walks loaded classes, which is the only way + // to accept the shorthand the UI offers without making the user type /Script/Module.Class. + if (const UClass* Found = UClass::TryFindTypeSlow(Trimmed, EFindFirstObjectOptions::ExactClass)) + { + Resolved = Found->GetClassPathName(); + } + } + + if (Resolved.IsNull()) + { + UnresolvedClasses.AddUnique(Trimmed); + UE_LOG(LogAssetUsageAudit, Warning, TEXT("Class filter '%s' did not resolve to a class; ignoring it."), *Trimmed); + return; + } + + RequestedClasses.AddUnique(Resolved); + RequestedDescriptions.AddUnique(Trimmed); + bCompiled = false; +} + +void FAssetTypeFilter::Compile(IAssetRegistry& AssetRegistry) +{ + ExpandedClasses.Reset(); + + if (RequestedClasses.IsEmpty()) + { + bCompiled = true; + return; + } + + TSet Derived; + AssetRegistry.GetDerivedClassNames(RequestedClasses, TSet(), Derived); + + // GetDerivedClassNames returns strict descendants in some builds and includes the seeds in + // others. Adding the seeds explicitly makes the result the same either way. + for (const FTopLevelAssetPath& ClassPath : RequestedClasses) + { + ExpandedClasses.Add(ClassPath); + } + + ExpandedClasses.Append(Derived); + + bCompiled = true; +} + +bool FAssetTypeFilter::Matches(const FAssetUsageNode& Node) const +{ + if (RequestedClasses.IsEmpty()) + { + return true; + } + + ensureMsgf(bCompiled, TEXT("FAssetTypeFilter::Matches called before Compile; subclasses will be missed.")); + + if (!Node.ClassPath.IsNull() && ExpandedClasses.Contains(Node.ClassPath)) + { + return true; + } + + // The Blueprint case. Asking "is this a StaticMeshActor" of a BP asset only works through the + // generated class, because the asset's own class is /Script/Engine.Blueprint. + if (!Node.GeneratedClassPath.IsNull() && ExpandedClasses.Contains(Node.GeneratedClassPath)) + { + return true; + } + + return false; +} + +FString FAssetTypeFilter::Describe() const +{ + if (RequestedDescriptions.IsEmpty()) + { + return TEXT("All types"); + } + + return FString::Join(RequestedDescriptions, TEXT(", ")); +} diff --git a/Source/AssetUsageAuditCore/Private/AssetUsageAuditCoreModule.cpp b/Source/AssetUsageAuditCore/Private/AssetUsageAuditCoreModule.cpp new file mode 100644 index 0000000..0b0563b --- /dev/null +++ b/Source/AssetUsageAuditCore/Private/AssetUsageAuditCoreModule.cpp @@ -0,0 +1,17 @@ +#include "AssetUsageAuditCoreModule.h" + +DEFINE_LOG_CATEGORY(LogAssetUsageAudit); + +#define LOCTEXT_NAMESPACE "FAssetUsageAuditCoreModule" + +void FAssetUsageAuditCoreModule::StartupModule() +{ +} + +void FAssetUsageAuditCoreModule::ShutdownModule() +{ +} + +#undef LOCTEXT_NAMESPACE + +IMPLEMENT_MODULE(FAssetUsageAuditCoreModule, AssetUsageAuditCore) diff --git a/Source/AssetUsageAuditCore/Private/AssetUsageAuditTypes.cpp b/Source/AssetUsageAuditCore/Private/AssetUsageAuditTypes.cpp new file mode 100644 index 0000000..ad90419 --- /dev/null +++ b/Source/AssetUsageAuditCore/Private/AssetUsageAuditTypes.cpp @@ -0,0 +1,77 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetUsageAuditTypes.h" + +const TCHAR* LexToString(EAssetUsageVerdict Verdict) +{ + switch (Verdict) + { + case EAssetUsageVerdict::UsedOnLevel: + return TEXT("UsedOnLevel"); + case EAssetUsageVerdict::UsedByAssetsOnly: + return TEXT("UsedByAssetsOnly"); + case EAssetUsageVerdict::ReferencedFromConfigOrSource: + return TEXT("ReferencedFromConfigOrSource"); + case EAssetUsageVerdict::Unreferenced: + return TEXT("Unreferenced"); + case EAssetUsageVerdict::Unknown: + return TEXT("Unknown"); + } + + return TEXT("Unknown"); +} + +TArray GetAllAssetUsageVerdicts() +{ + // Most actionable first: this order is what the filter menu shows, and "used on a level" is the + // question people open the tool with. + return { + EAssetUsageVerdict::UsedOnLevel, + EAssetUsageVerdict::UsedByAssetsOnly, + EAssetUsageVerdict::ReferencedFromConfigOrSource, + EAssetUsageVerdict::Unreferenced, + EAssetUsageVerdict::Unknown + }; +} + +const TCHAR* LexToString(EExportLayout Layout) +{ + switch (Layout) + { + case EExportLayout::Flat: return TEXT("Flat"); + case EExportLayout::MirrorTree: return TEXT("MirrorTree"); + case EExportLayout::FolderPerAsset: return TEXT("FolderPerAsset"); + case EExportLayout::Migrate: return TEXT("Migrate"); + } + + return TEXT("Flat"); +} + +FString ProvenanceToString(EAssetUsageProvenance Provenance) +{ + if (Provenance == EAssetUsageProvenance::None) + { + return TEXT("None"); + } + + TArray Parts; + + auto Append = [&Parts, Provenance](EAssetUsageProvenance Flag, const TCHAR* Label) + { + if (EnumHasAnyFlags(Provenance, Flag)) + { + Parts.Add(Label); + } + }; + + Append(EAssetUsageProvenance::HardReference, TEXT("Hard")); + Append(EAssetUsageProvenance::SoftReference, TEXT("Soft")); + Append(EAssetUsageProvenance::ExternalActor, TEXT("ExternalActor")); + Append(EAssetUsageProvenance::Sublevel, TEXT("Sublevel")); + Append(EAssetUsageProvenance::ConfigFile, TEXT("Config")); + Append(EAssetUsageProvenance::SourceFile, TEXT("Source")); + Append(EAssetUsageProvenance::EditorOnly, TEXT("EditorOnly")); + Append(EAssetUsageProvenance::Redirector, TEXT("Redirector")); + + return FString::Join(Parts, TEXT("|")); +} diff --git a/Source/AssetUsageAuditCore/Private/AssetUsageAuditor.cpp b/Source/AssetUsageAuditCore/Private/AssetUsageAuditor.cpp new file mode 100644 index 0000000..3135004 --- /dev/null +++ b/Source/AssetUsageAuditCore/Private/AssetUsageAuditor.cpp @@ -0,0 +1,274 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetUsageAuditor.h" + +#include "AssetUsageAuditCoreModule.h" +#include "AssetUsagePaths.h" +#include "IndirectReferenceScanner.h" +#include "AssetRegistry/IAssetRegistry.h" +#include "HAL/PlatformTime.h" +#include "Misc/App.h" +#include "Misc/EngineVersion.h" + +namespace +{ + /** Package roots whose assets the registry cannot meaningfully track. */ + bool IsBlindSpotPackage(FName PackageName) + { + TStringBuilder<256> Builder; + PackageName.ToString(Builder); + const FStringView Path = Builder.ToView(); + + // FMOD banks and events are addressed by string path through the FMOD Studio runtime. + // Nothing in the UObject graph references them, so "no referencers" means nothing here. + return UE::String::FindFirst(Path, TEXTVIEW("/FMOD/"), ESearchCase::IgnoreCase) != INDEX_NONE; + } + + bool IsBlindSpotClass(const FTopLevelAssetPath& ClassPath) + { + if (ClassPath.IsNull()) + { + return false; + } + + // Anything from the FMOD module: events and banks are addressed by string, never referenced. + if (ClassPath.GetPackageName().ToString().StartsWith(TEXT("/Script/FMODStudio"), ESearchCase::IgnoreCase)) + { + return true; + } + + // Data tables and curve tables are a subtler blind spot, and it cuts both ways. A consumer + // depends on the whole table, so every row inside it looks used even when nothing reads it; + // and rows holding paths as plain strings rather than TSoftObjectPtr produce no edge at all. + // Neither "used" nor "unused" is defensible for the table's contents. + static const TSet BlindSpotClasses = { + FTopLevelAssetPath(TEXT("/Script/Engine.DataTable")), + FTopLevelAssetPath(TEXT("/Script/Engine.CurveTable")) + }; + + return BlindSpotClasses.Contains(ClassPath); + } +} + +int32 FAssetUsageAuditResult::CountByVerdict(EAssetUsageVerdict Verdict) const +{ + int32 Count = 0; + for (const FAssetUsageRow& Row : Rows) + { + if (Row.Verdict == Verdict) + { + ++Count; + } + } + return Count; +} + +bool FAssetUsageAuditor::IsRegistryBlindSpot(const FAssetUsageNode& Node) +{ + return IsBlindSpotPackage(Node.PackageName) || IsBlindSpotClass(Node.ClassPath); +} + +EAssetUsageVerdict FAssetUsageAuditor::ClassifyVerdict(bool bReachableFromLevel, bool bHasReferencers, bool bFoundInConfigOrSource, bool bIsBlindSpot) +{ + // Order matters, and it is chosen so the tool never overstates. + // + // Positive evidence wins over blind-spot status: if a level demonstrably reaches an FMOD bank, + // saying "Unknown" would throw away a fact we actually have. + if (bReachableFromLevel) + { + return EAssetUsageVerdict::UsedOnLevel; + } + + if (bFoundInConfigOrSource) + { + return EAssetUsageVerdict::ReferencedFromConfigOrSource; + } + + if (bHasReferencers) + { + return EAssetUsageVerdict::UsedByAssetsOnly; + } + + // Absence of evidence only becomes Unreferenced when absence is actually informative. + // For a blind spot it is not, so the honest answer is Unknown. + if (bIsBlindSpot) + { + return EAssetUsageVerdict::Unknown; + } + + return EAssetUsageVerdict::Unreferenced; +} + +FAssetUsageAuditResult FAssetUsageAuditor::Run(IAssetRegistry& AssetRegistry, FAssetUsageAuditRequest& Request) +{ + FAssetUsageAuditResult Result; + + const double StartTime = FPlatformTime::Seconds(); + + // --- 1. Graph ----------------------------------------------------------------------------- + FAssetUsageGraphBuildOptions GraphOptions; + GraphOptions.IncludePackagePaths = Request.IncludePackagePaths; + GraphOptions.ExcludePackagePaths = Request.ExcludePackagePaths; + GraphOptions.bGatherDependencies = true; + + FAssetUsageGraph Graph; + Graph.Build(AssetRegistry, GraphOptions); + + Result.GraphStats = Graph.GetStats(); + + if (Graph.Num() == 0) + { + UE_LOG(LogAssetUsageAudit, Warning, TEXT("Asset graph is empty. Is the Asset Registry still scanning?")); + return Result; + } + + // --- 2. Level reachability ---------------------------------------------------------------- + FLevelUsageResolveOptions ResolveOptions = Request.ResolveOptions; + ResolveOptions.LevelPackages = Request.LevelPackages; + + FLevelUsageResolver Resolver(Graph, AssetRegistry); + const FLevelUsageResult LevelResult = Resolver.Resolve(ResolveOptions); + + Result.LevelStats = LevelResult.Stats; + + // --- 3. Indirect references --------------------------------------------------------------- + TMap IndirectProvenance; + if (Request.bScanIndirectReferences) + { + for (const FIndirectReference& Reference : FIndirectReferenceScanner::Scan(FIndirectReferenceScanner::MakeDefaultOptions())) + { + FString& Existing = IndirectProvenance.FindOrAdd(Reference.PackageName); + if (Existing.IsEmpty()) + { + Existing = Reference.ToProvenanceString(); + } + } + } + + // --- 4. Rows ------------------------------------------------------------------------------ + Request.TypeFilter.Compile(AssetRegistry); + + // Levels-per-asset the cheap way: invert once, rather than calling GetLevelsForAsset per row, + // which would rescan every level bitset for every asset. + TArray> LevelsPerAsset; + LevelsPerAsset.SetNum(Graph.Num()); + + for (int32 LevelOrdinal = 0; LevelOrdinal < LevelResult.LevelReachability.Num(); ++LevelOrdinal) + { + const TBitArray<>& Bits = LevelResult.LevelReachability[LevelOrdinal]; + const FName LevelName = LevelResult.LevelPackageNames[LevelOrdinal]; + + for (TConstSetBitIterator<> It(Bits); It; ++It) + { + LevelsPerAsset[It.GetIndex()].Add(LevelName); + } + } + + Result.Rows.Reserve(Graph.Num() / 2); + + for (int32 Index = 0; Index < Graph.Num(); ++Index) + { + const FAssetUsageNode& Node = Graph.GetNode(Index); + + if (Request.bHideExternalPackages && Node.bIsExternalPackage) + { + continue; + } + + if (Request.bHideRedirectors && Node.bIsRedirector) + { + continue; + } + + // Only project content can be exported or acted on; engine and plugin packages are noise. + const FString RelativePath = AssetUsagePaths::ToProjectRelativePath(Node.PackageName, Node.bIsLevel); + if (RelativePath.IsEmpty()) + { + continue; + } + + if (!Request.TypeFilter.Matches(Node)) + { + continue; + } + + const bool bReachable = LevelResult.IsReachableFromAnyLevel(Index); + const bool bHasReferencers = Graph.GetReferencers(Index).Num() > 0; + const FString* IndirectDetail = IndirectProvenance.Find(Node.PackageName); + const bool bIsBlindSpot = IsRegistryBlindSpot(Node); + + const EAssetUsageVerdict Verdict = ClassifyVerdict(bReachable, bHasReferencers, IndirectDetail != nullptr, bIsBlindSpot); + + if (Request.bOnlyUnusedAssets && Verdict == EAssetUsageVerdict::UsedOnLevel) + { + continue; + } + + FAssetUsageRow& Row = Result.Rows.AddDefaulted_GetRef(); + Row.PackageName = Node.PackageName; + Row.AssetName = Node.AssetName; + Row.ClassPath = Node.ClassPath; + Row.GeneratedClassPath = Node.GeneratedClassPath; + Row.PathFromProjectRoot = RelativePath; + Row.Verdict = Verdict; + Row.Levels = MoveTemp(LevelsPerAsset[Index]); + + if (LevelResult.Provenance.IsValidIndex(Index)) + { + Row.Provenance = LevelResult.Provenance[Index]; + } + + if (LevelResult.HardReferenceCounts.IsValidIndex(Index)) + { + Row.HardReferenceCount = LevelResult.HardReferenceCounts[Index]; + Row.SoftReferenceCount = LevelResult.SoftReferenceCounts[Index]; + } + + if (const FString* Route = LevelResult.Routes.Find(Index)) + { + Row.Route = *Route; + } + + if (IndirectDetail) + { + Row.ProvenanceDetail = *IndirectDetail; + Row.Provenance |= EAssetUsageProvenance::ConfigFile; + } + } + + // --- 5. Header ---------------------------------------------------------------------------- + Result.Header.GeneratedAt = FDateTime::Now(); + Result.Header.EngineVersion = FEngineVersion::Current().ToString(); + Result.Header.ToolVersion = TEXT("0.1"); + Result.Header.ProjectName = FApp::GetProjectName(); + Result.Header.LevelsScanned = LevelResult.Stats.LevelsScanned; + Result.Header.AssetsScanned = Graph.Num(); + Result.Header.ScanDurationSeconds = FPlatformTime::Seconds() - StartTime; + + Result.Header.AppliedFilters.Add(FString::Printf(TEXT("Types: %s"), *Request.TypeFilter.Describe())); + Result.Header.AppliedFilters.Add(FString::Printf(TEXT("Include paths: %s"), + Request.IncludePackagePaths.IsEmpty() ? TEXT("/Game") : *FString::Join(Request.IncludePackagePaths, TEXT(", ")))); + + if (!Request.ExcludePackagePaths.IsEmpty()) + { + Result.Header.AppliedFilters.Add(FString::Printf(TEXT("Excluded paths: %s"), *FString::Join(Request.ExcludePackagePaths, TEXT(", ")))); + } + + Result.Header.AppliedFilters.Add(FString::Printf(TEXT("Levels: %s"), + Request.LevelPackages.IsEmpty() ? TEXT("all") : *FString::Printf(TEXT("%d selected"), Request.LevelPackages.Num()))); + Result.Header.AppliedFilters.Add(FString::Printf(TEXT("Indirect scan: %s"), Request.bScanIndirectReferences ? TEXT("on") : TEXT("off"))); + Result.Header.AppliedFilters.Add(FString::Printf(TEXT("Only unused: %s"), Request.bOnlyUnusedAssets ? TEXT("yes") : TEXT("no"))); + + UE_LOG(LogAssetUsageAudit, Log, + TEXT("Audit complete in %.2fs: %d rows (%d on level, %d assets-only, %d config/source, %d unreferenced, %d unknown) across %d levels."), + Result.Header.ScanDurationSeconds, + Result.Rows.Num(), + Result.CountByVerdict(EAssetUsageVerdict::UsedOnLevel), + Result.CountByVerdict(EAssetUsageVerdict::UsedByAssetsOnly), + Result.CountByVerdict(EAssetUsageVerdict::ReferencedFromConfigOrSource), + Result.CountByVerdict(EAssetUsageVerdict::Unreferenced), + Result.CountByVerdict(EAssetUsageVerdict::Unknown), + Result.Header.LevelsScanned); + + return Result; +} diff --git a/Source/AssetUsageAuditCore/Private/AssetUsageExporter.cpp b/Source/AssetUsageAuditCore/Private/AssetUsageExporter.cpp new file mode 100644 index 0000000..bf78e60 --- /dev/null +++ b/Source/AssetUsageAuditCore/Private/AssetUsageExporter.cpp @@ -0,0 +1,601 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetUsageExporter.h" + +#include "AssetExportNaming.h" +#include "AssetUsageAuditCoreModule.h" +#include "AssetUsagePaths.h" +#include "AssetExportTask.h" +#include "Engine/Level.h" +#include "Exporters/Exporter.h" +#include "HAL/FileManager.h" +#include "HAL/PlatformFileManager.h" +#include "Misc/PackageName.h" +#include "Misc/Paths.h" +#include "UObject/Class.h" +#include "UObject/StrongObjectPtr.h" +#include "UObject/UObjectGlobals.h" + +FString FAssetUsageExporter::FResult::Summarise() const +{ + TArray Parts; + + Parts.Add(FString::Printf(TEXT("%d copied"), FilesCopied)); + + if (FilesRenamed > 0) + { + Parts.Add(FString::Printf(TEXT("%d renamed to avoid a collision"), FilesRenamed)); + } + + if (FilesOverwritten > 0) + { + Parts.Add(FString::Printf(TEXT("%d overwritten"), FilesOverwritten)); + } + + if (ExternalPackagesCopied > 0) + { + Parts.Add(FString::Printf(TEXT("%d external actor packages"), ExternalPackagesCopied)); + } + + if (FilesMissingOnDisk > 0) + { + Parts.Add(FString::Printf(TEXT("%d had no file on disk"), FilesMissingOnDisk)); + } + + if (Errors.Num() > 0) + { + Parts.Add(FString::Printf(TEXT("%d failed"), Errors.Num())); + } + + if (bCancelled) + { + Parts.Add(TEXT("cancelled before finishing")); + } + + return FString::Join(Parts, TEXT(", ")); +} + +FString FAssetUsageExporter::FExchangeResult::Summarise() const +{ + TArray Parts; + + Parts.Add(FString::Printf(TEXT("%d written"), FilesWritten)); + + if (FilesRenamed > 0) + { + Parts.Add(FString::Printf(TEXT("%d renamed to avoid a collision"), FilesRenamed)); + } + + // Each skip reason is reported separately on purpose. "17 skipped" tells a user nothing, while + // "17 have no configured format" points straight at the settings they need to change. + if (SkippedNoFormat > 0) + { + Parts.Add(FString::Printf(TEXT("%d had no configured format"), SkippedNoFormat)); + } + + if (SkippedNoExporter > 0) + { + Parts.Add(FString::Printf(TEXT("%d had no exporter for that format"), SkippedNoExporter)); + } + + if (SkippedNotLoaded > 0) + { + Parts.Add(FString::Printf(TEXT("%d could not be loaded"), SkippedNotLoaded)); + } + + if (Errors.Num() > 0) + { + Parts.Add(FString::Printf(TEXT("%d failed"), Errors.Num())); + } + + if (bCancelled) + { + Parts.Add(TEXT("cancelled before finishing")); + } + + return FString::Join(Parts, TEXT(", ")); +} + +TMap FAssetUsageExporter::GetDefaultFormatByClass() +{ + // Only classes the engine actually ships a UExporter for. A plausible-looking entry with no + // exporter behind it turns "nothing came out" into a mystery, so anything unverified is left + // out and reported as SkippedNoFormat instead. + // + // Keys are class short names and lookup walks up the hierarchy, so MaterialInterface covers + // material instances and Texture covers every 2D texture subclass. + return { + { TEXT("StaticMesh"), TEXT("fbx") }, + { TEXT("SkeletalMesh"), TEXT("fbx") }, + { TEXT("AnimSequence"), TEXT("fbx") }, + { TEXT("Texture2D"), TEXT("png") }, + { TEXT("TextureCube"), TEXT("hdr") }, + { TEXT("SoundWave"), TEXT("wav") }, + { TEXT("DataTable"), TEXT("csv") }, + { TEXT("CurveTable"), TEXT("csv") }, + { TEXT("FontFace"), TEXT("ttf") }, + { TEXT("World"), TEXT("fbx") } + }; +} + +FString FAssetUsageExporter::FindFormatForClass(const FTopLevelAssetPath& ClassPath, const TMap& FormatByClass) +{ + if (!ClassPath.IsValid()) + { + return FString(); + } + + const FString ShortName = ClassPath.GetAssetName().ToString(); + + if (const FString* Direct = FormatByClass.Find(ShortName)) + { + return *Direct; + } + + // Walk the hierarchy so one entry covers a family. FindObject rather than LoadObject: a native + // class is already loaded, and a Blueprint class we cannot find has no exporter anyway. + UClass* Class = FindObject(ClassPath); + + for (UClass* Super = Class ? Class->GetSuperClass() : nullptr; Super; Super = Super->GetSuperClass()) + { + if (const FString* Found = FormatByClass.Find(Super->GetName())) + { + return *Found; + } + } + + return FString(); +} + +FAssetUsageExporter::FExchangeResult FAssetUsageExporter::ExportConvertedAssets(const TArray& Items, const FExchangeOptions& Options) +{ + FExchangeResult Result; + + if (Options.TargetDirectory.IsEmpty()) + { + Result.Errors.Add(TEXT("No target directory was given.")); + UE_LOG(LogAssetUsageAudit, Error, TEXT("Conversion aborted: no target directory was given.")); + return Result; + } + + IFileManager& FileManager = IFileManager::Get(); + + if (!FileManager.DirectoryExists(*Options.TargetDirectory) + && !FileManager.MakeDirectory(*Options.TargetDirectory, /*Tree=*/true)) + { + Result.Errors.Add(FString::Printf(TEXT("Could not create '%s'."), *Options.TargetDirectory)); + UE_LOG(LogAssetUsageAudit, Error, TEXT("Conversion aborted: could not create target directory '%s'."), *Options.TargetDirectory); + return Result; + } + + const TMap& FormatByClass = Options.FormatByClass.Num() > 0 + ? Options.FormatByClass + : GetDefaultFormatByClass(); + + const int32 Total = Items.Num(); + int32 LoadedSinceCollect = 0; + + for (int32 Index = 0; Index < Total; ++Index) + { + if (Options.OnProgress && !Options.OnProgress(Index, Total)) + { + Result.bCancelled = true; + break; + } + + const FExportItem& Item = Items[Index]; + + const FString Extension = FindFormatForClass(Item.ClassPath, FormatByClass); + if (Extension.IsEmpty()) + { + // Decided before loading anything: there is no point paying to load a Blueprint just to + // discover the engine cannot write it to a file. + ++Result.SkippedNoFormat; + continue; + } + + const FString ObjectPath = FString::Printf(TEXT("%s.%s"), *Item.PackageName.ToString(), *Item.AssetName.ToString()); + + UObject* Asset = LoadObject(nullptr, *ObjectPath); + if (!Asset) + { + ++Result.SkippedNotLoaded; + UE_LOG(LogAssetUsageAudit, Warning, TEXT("Could not load '%s' for conversion."), *ObjectPath); + continue; + } + + FString DestinationDir = Options.TargetDirectory; + + if (!Options.bFlatten) + { + FString Relative = FPaths::GetPath(Item.PackageName.ToString()); + Relative.RemoveFromStart(TEXT("/")); + DestinationDir = FPaths::Combine(Options.TargetDirectory, Relative); + + if (!FileManager.DirectoryExists(*DestinationDir) && !FileManager.MakeDirectory(*DestinationDir, true)) + { + Result.Errors.Add(FString::Printf(TEXT("Could not create '%s'."), *DestinationDir)); + continue; + } + } + + const FString DesiredFileName = FString::Printf(TEXT("%s.%s"), *Item.AssetName.ToString(), *Extension); + + const FString FinalFileName = AssetExportNaming::ResolveCollision( + DesiredFileName, + Options.CollisionPolicy, + [&FileManager, &DestinationDir](const FString& Candidate) + { + return FileManager.FileExists(*FPaths::Combine(DestinationDir, Candidate)); + }); + + const FString DestinationPath = FPaths::Combine(DestinationDir, FinalFileName); + + if (FileManager.FileExists(*DestinationPath) && FileManager.IsReadOnly(*DestinationPath)) + { + // IFileManager can report the flag but not change it; only IPlatformFile can. + FPlatformFileManager::Get().GetPlatformFile().SetReadOnly(*DestinationPath, false); + } + + // TStrongObjectPtr, not a bare NewObject: this loop runs garbage collection, and a task + // rooted nowhere would be destroyed underneath the exporter. + TStrongObjectPtr Task(NewObject()); + Task->Object = Asset; + Task->Exporter = nullptr; // let the engine match SupportedClass against the extension + Task->Filename = DestinationPath; + Task->bSelected = false; + Task->bReplaceIdentical = true; + Task->bPrompt = false; // a modal dialog per asset would hang an unattended run + Task->bAutomated = true; + Task->bUseFileArchive = false; + Task->bWriteEmptyFiles = false; + + const bool bRan = UExporter::RunAssetExportTask(Task.Get()); + + // Both halves matter. RunAssetExportTask returns false when no exporter matched, and it can + // also return true having filled Errors - reading only the bool reports a success that + // produced nothing. + if (!bRan) + { + if (Task->Errors.Num() > 0) + { + for (const FString& TaskError : Task->Errors) + { + Result.Errors.Add(FString::Printf(TEXT("%s: %s"), *Item.AssetName.ToString(), *TaskError)); + } + } + else + { + ++Result.SkippedNoExporter; + } + } + else if (Task->Errors.Num() > 0) + { + for (const FString& TaskError : Task->Errors) + { + Result.Errors.Add(FString::Printf(TEXT("%s: %s"), *Item.AssetName.ToString(), *TaskError)); + } + } + else if (!FileManager.FileExists(*DestinationPath)) + { + // An exporter that reports success without producing a file is the one failure mode a + // caller would never notice. Say so rather than counting a write that did not happen. + Result.Errors.Add(FString::Printf( + TEXT("%s: the exporter reported success but wrote no file at '%s'."), + *Item.AssetName.ToString(), *DestinationPath)); + } + else + { + ++Result.FilesWritten; + + if (FinalFileName != DesiredFileName) + { + ++Result.FilesRenamed; + } + } + + // Loading thousands of meshes without collecting exhausts memory long before the export + // finishes. The task is released first so it is not what keeps the asset alive. + Task.Reset(); + + if (Options.CollectGarbageEvery > 0 && ++LoadedSinceCollect >= Options.CollectGarbageEvery) + { + LoadedSinceCollect = 0; + CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS); + } + } + + Result.bSuccess = Result.Errors.IsEmpty() && !Result.bCancelled; + + UE_LOG(LogAssetUsageAudit, Log, TEXT("Conversion to '%s': %s"), *Options.TargetDirectory, *Result.Summarise()); + + for (const FString& Error : Result.Errors) + { + UE_LOG(LogAssetUsageAudit, Warning, TEXT(" %s"), *Error); + } + + return Result; +} + +FString FAssetUsageExporter::ResolvePackageFilePath(FName PackageName) +{ + const FString PackageNameStr = PackageName.ToString(); + + if (!FPackageName::IsValidLongPackageName(PackageNameStr)) + { + return FString(); + } + + // A package is either an asset or a map and we do not know which from the name alone. + // DoesPackageExist resolves the extension for us and also copes with mount points other + // than /Game, which matters for plugin content. + FString ResolvedFileName; + if (FPackageName::DoesPackageExist(PackageNameStr, &ResolvedFileName)) + { + return ResolvedFileName; + } + + return FString(); +} + +FAssetUsageExporter::FResult FAssetUsageExporter::ExportPackageFiles(const TArray& PackageNames, const FOptions& Options) +{ + // One unnamed group: everything straight into the target, which is what this function has + // always done. Kept as its own entry point because most callers and every existing spec want + // exactly this and should not have to build a group to say so. + FExportGroup Group; + Group.Packages = PackageNames; + + return ExportPackageGroups({ MoveTemp(Group) }, Options); +} + +FAssetUsageExporter::FResult FAssetUsageExporter::ExportPackageGroups(const TArray& Groups, const FOptions& Options) +{ + FResult Result; + + // Setup failures return before the summary log at the bottom of this function, so they must + // log for themselves. These two are the most likely things to go wrong and were previously + // the only failures that left no trace at all - the UI would say "see the Output Log" and the + // Output Log would be empty. + if (Options.TargetDirectory.IsEmpty()) + { + Result.Errors.Add(TEXT("No target directory was given.")); + UE_LOG(LogAssetUsageAudit, Error, TEXT("Export aborted: no target directory was given.")); + return Result; + } + + IFileManager& FileManager = IFileManager::Get(); + + if (!FileManager.DirectoryExists(*Options.TargetDirectory)) + { + if (!FileManager.MakeDirectory(*Options.TargetDirectory, /*Tree=*/true)) + { + Result.Errors.Add(FString::Printf(TEXT("Could not create '%s'."), *Options.TargetDirectory)); + UE_LOG(LogAssetUsageAudit, Error, + TEXT("Export aborted: could not create target directory '%s'. Check the path is valid and writable ") + TEXT("- a Perforce-read-only parent or a non-existent drive letter both land here."), + *Options.TargetDirectory); + return Result; + } + } + + // Flatten the groups into one work list, remembering each entry's destination folder. Doing it + // up front keeps the progress total honest: a caller showing a bar must not watch the maximum + // creep upward as levels expand mid-copy. + // + // Deduplication is per group, not global. The same texture legitimately appears in several + // groups under the folder-per-asset layout - that is the whole point of that layout - so a + // global Seen set would silently give the first folder the texture and leave the rest without. + TArray Expanded; + TArray DestinationDirs; + + // Parallel to Expanded: true for entries this function added by expanding a level, rather than + // entries the caller asked for. Counted at copy time, not here - reporting "N external actor + // packages" for packages that were only queued would overstate a cancelled or failing run. + TBitArray<> IsExternalExpansion; + + for (const FExportGroup& Group : Groups) + { + const FString GroupDir = Group.RelativeDir.IsEmpty() + ? Options.TargetDirectory + : FPaths::Combine(Options.TargetDirectory, Group.RelativeDir); + + const int32 FirstIndexInGroup = Expanded.Num(); + + TSet Seen; + Seen.Reserve(Group.Packages.Num()); + + for (FName PackageName : Group.Packages) + { + if (!Seen.Contains(PackageName)) + { + Seen.Add(PackageName); + Expanded.Add(PackageName); + DestinationDirs.Add(GroupDir); + IsExternalExpansion.Add(false); + } + } + + if (!Options.bIncludeExternalPackages) + { + continue; + } + + const int32 LastIndexInGroup = Expanded.Num(); + + for (int32 Index = FirstIndexInGroup; Index < LastIndexInGroup; ++Index) + { + const FString PackageNameStr = Expanded[Index].ToString(); + + // Only a World owns external packages. Asking for the paths of a non-level is harmless + // but pointless, and on 45k rows it is not free. + const FString FilePath = ResolvePackageFilePath(Expanded[Index]); + if (!FilePath.EndsWith(FPackageName::GetMapPackageExtension())) + { + continue; + } + + // Plural form: plugins register extra roots through delegates, and Content Bundles and + // External Data Layers inject path segments a hand-built string would miss. + TArray ExternalRoots = ULevel::GetExternalActorsPaths(PackageNameStr); + ExternalRoots.Append(ULevel::GetExternalObjectsPaths(PackageNameStr)); + + for (const FString& Root : ExternalRoots) + { + FString RootDir; + if (!FPackageName::TryConvertLongPackageNameToFilename(Root, RootDir)) + { + continue; + } + + TArray Found; + FileManager.FindFilesRecursive(Found, *RootDir, TEXT("*.uasset"), /*Files=*/true, /*Directories=*/false); + + for (const FString& AbsFile : Found) + { + FString ExternalPackageName; + if (FPackageName::TryConvertFilenameToLongPackageName(AbsFile, ExternalPackageName)) + { + const FName AsName(*ExternalPackageName); + if (!Seen.Contains(AsName)) + { + Seen.Add(AsName); + Expanded.Add(AsName); + DestinationDirs.Add(GroupDir); + IsExternalExpansion.Add(true); + } + } + } + } + } + } + + return CopyExpandedPackages(Expanded, DestinationDirs, IsExternalExpansion, Options, Result); +} + +FAssetUsageExporter::FResult FAssetUsageExporter::CopyExpandedPackages(const TArray& Expanded, const TArray& DestinationDirs, const TBitArray<>& IsExternalExpansion, const FOptions& Options, FResult& Result) +{ + IFileManager& FileManager = IFileManager::Get(); + + const int32 Total = Expanded.Num(); + + for (int32 Index = 0; Index < Total; ++Index) + { + if (Options.OnProgress && !Options.OnProgress(Index, Total)) + { + Result.bCancelled = true; + break; + } + + const FName PackageName = Expanded[Index]; + const FString SourcePath = ResolvePackageFilePath(PackageName); + + if (SourcePath.IsEmpty()) + { + // Normal for script packages and for assets that exist only in memory. Counted, not + // reported as an error, so a real failure stays visible in the Errors list. + ++Result.FilesMissingOnDisk; + continue; + } + + // The group decided the folder; bFlatten only chooses whether to mirror the package path + // beneath it. The two compose: a folder-per-asset export with mirroring off writes straight + // into the asset's folder, which is what makes that layout readable. + FString DestinationDir = DestinationDirs.IsValidIndex(Index) + ? DestinationDirs[Index] + : Options.TargetDirectory; + + if (!Options.bFlatten) + { + // Mirror the package path under the target, minus the leading mount point slash. + FString Relative = FPaths::GetPath(PackageName.ToString()); + Relative.RemoveFromStart(TEXT("/")); + DestinationDir = FPaths::Combine(DestinationDir, Relative); + } + + if (DestinationDir != Options.TargetDirectory) + { + if (!FileManager.DirectoryExists(*DestinationDir) && !FileManager.MakeDirectory(*DestinationDir, true)) + { + Result.Errors.Add(FString::Printf(TEXT("Could not create '%s'."), *DestinationDir)); + continue; + } + } + + const FString DesiredFileName = FPaths::GetCleanFilename(SourcePath); + + const FString FinalFileName = AssetExportNaming::ResolveCollision( + DesiredFileName, + Options.CollisionPolicy, + [&FileManager, &DestinationDir](const FString& Candidate) + { + return FileManager.FileExists(*FPaths::Combine(DestinationDir, Candidate)); + }); + + const FString DestinationPath = FPaths::Combine(DestinationDir, FinalFileName); + + const bool bWasPresent = FileManager.FileExists(*DestinationPath); + + // This repository is Perforce-primary, so the source .uasset is very often read-only on + // disk. Windows CopyFile carries that attribute across, which would make the export folder + // read-only too - and then an Overwrite-policy re-export fails on its own previous output. + // Clear the flag on both ends of the problem: before writing over an existing file, and + // after writing a new one. The destination is outside source control by definition. + if (bWasPresent && FileManager.IsReadOnly(*DestinationPath)) + { + // IFileManager can report the flag but not change it; only IPlatformFile can. + FPlatformFileManager::Get().GetPlatformFile().SetReadOnly(*DestinationPath, false); + } + + if (FileManager.Copy(*DestinationPath, *SourcePath, /*bReplace=*/true) != COPY_OK) + { + Result.Errors.Add(FString::Printf(TEXT("Failed to copy '%s' to '%s'."), *PackageName.ToString(), *DestinationPath)); + continue; + } + + if (FileManager.IsReadOnly(*DestinationPath)) + { + // IFileManager can report the flag but not change it; only IPlatformFile can. + FPlatformFileManager::Get().GetPlatformFile().SetReadOnly(*DestinationPath, false); + } + + ++Result.FilesCopied; + + if (Options.bRecordWrittenFiles) + { + // Relative to the export root, so the manifest stays valid if the folder is moved or + // renamed - which is the normal fate of an export folder. + FString Relative = DestinationPath; + FPaths::MakePathRelativeTo(Relative, *(Options.TargetDirectory / TEXT(""))); + + FWrittenFile& Written = Result.WrittenFiles.AddDefaulted_GetRef(); + Written.PackageName = PackageName; + Written.RelativePath = MoveTemp(Relative); + } + + if (IsExternalExpansion.IsValidIndex(Index) && IsExternalExpansion[Index]) + { + ++Result.ExternalPackagesCopied; + } + + if (FinalFileName != DesiredFileName) + { + ++Result.FilesRenamed; + } + else if (bWasPresent) + { + ++Result.FilesOverwritten; + } + } + + Result.bSuccess = Result.Errors.IsEmpty() && !Result.bCancelled; + + UE_LOG(LogAssetUsageAudit, Log, TEXT("Export to '%s': %s"), *Options.TargetDirectory, *Result.Summarise()); + + for (const FString& Error : Result.Errors) + { + UE_LOG(LogAssetUsageAudit, Warning, TEXT(" %s"), *Error); + } + + return Result; +} diff --git a/Source/AssetUsageAuditCore/Private/AssetUsageGraph.cpp b/Source/AssetUsageAuditCore/Private/AssetUsageGraph.cpp new file mode 100644 index 0000000..339c9be --- /dev/null +++ b/Source/AssetUsageAuditCore/Private/AssetUsageGraph.cpp @@ -0,0 +1,369 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetUsageGraph.h" +#include "AssetUsageAuditCoreModule.h" +#include "AssetUsagePaths.h" +#include "AssetRegistry/ARFilter.h" +#include "AssetRegistry/IAssetRegistry.h" +#include "Engine/World.h" +#include "HAL/PlatformTime.h" +#include "Misc/PackageName.h" +#include "Misc/ScopeExit.h" +#include "UObject/ObjectRedirector.h" + +namespace +{ + /** Asset registry tag carrying a Blueprint's generated gameplay class. */ + const FName GeneratedClassTagName(TEXT("GeneratedClass")); + + /** Redirector chains are shallow in practice; this only guards against a malformed cycle. */ + constexpr int32 MaxRedirectorHops = 16; +} + +void FAssetUsageGraph::Reset() +{ + Nodes.Reset(); + PackageToIndex.Reset(); + Dependencies.Reset(); + DependencyOffsets.Reset(); + Referencers.Reset(); + ReferencerOffsets.Reset(); + LevelIndices.Reset(); + Stats = FAssetUsageGraphStats(); +} + +int32 FAssetUsageGraph::AddOrFindPackage(FName PackageName) +{ + if (const int32* Existing = PackageToIndex.Find(PackageName)) + { + return *Existing; + } + + const int32 NewIndex = Nodes.Num(); + + FAssetUsageNode& Node = Nodes.AddDefaulted_GetRef(); + Node.PackageName = PackageName; + Node.bIsExternalPackage = AssetUsagePaths::IsExternalPackage(PackageName); + + PackageToIndex.Add(PackageName, NewIndex); + + return NewIndex; +} + +void FAssetUsageGraph::Build(IAssetRegistry& AssetRegistry, const FAssetUsageGraphBuildOptions& Options) +{ + Reset(); + + // Temporary caching makes repeated class-hierarchy and tag lookups cheap for the duration of + // the sweep. GetDerivedClassNames in particular is documented as slow without it. + AssetRegistry.SetTemporaryCachingMode(true); + ON_SCOPE_EXIT + { + AssetRegistry.SetTemporaryCachingMode(false); + }; + + FARFilter Filter; + Filter.bRecursivePaths = true; + + // On-disk only: unsaved in-memory assets have no dependency data yet, and including them + // makes the result depend on which windows the user happens to have open. + Filter.bIncludeOnlyOnDiskAssets = true; + + if (Options.IncludePackagePaths.IsEmpty()) + { + Filter.PackagePaths.Add(FName(TEXT("/Game"))); + } + else + { + for (const FString& Path : Options.IncludePackagePaths) + { + Filter.PackagePaths.Add(FName(*Path)); + } + } + + FARCompiledFilter CompiledFilter; + AssetRegistry.CompileFilter(Filter, CompiledFilter); + + const FTopLevelAssetPath WorldClassPath = UWorld::StaticClass()->GetClassPathName(); + const FTopLevelAssetPath RedirectorClassPath = UObjectRedirector::StaticClass()->GetClassPathName(); + + // --- Pass 1: inventory ------------------------------------------------------------------- + // EnumerateAssets rather than GetAssets: at 80k assets the latter materialises one giant + // TArray before we have looked at a single element. + { + const double StartTime = FPlatformTime::Seconds(); + + AssetRegistry.EnumerateAssets(CompiledFilter, [this, &Options, &WorldClassPath, &RedirectorClassPath](const FAssetData& AssetData) + { + const FName PackageName = AssetData.PackageName; + + if (AssetUsagePaths::IsPathExcluded(PackageName, Options.ExcludePackagePaths)) + { + return true; + } + + const int32 Index = AddOrFindPackage(PackageName); + FAssetUsageNode& Node = Nodes[Index]; + + // A package can hold several assets; keep the first as representative but never let a + // secondary asset downgrade a class we already recorded. + if (Node.AssetName.IsNone()) + { + Node.AssetName = AssetData.AssetName; + Node.ClassPath = AssetData.AssetClassPath; + } + + if (AssetData.AssetClassPath == WorldClassPath) + { + Node.bIsLevel = true; + Node.ClassPath = WorldClassPath; + } + + if (AssetData.AssetClassPath == RedirectorClassPath) + { + Node.bIsRedirector = true; + } + + // The GeneratedClass tag is the only route from a Blueprint asset to its gameplay + // class; the asset's own class is always /Script/Engine.Blueprint. + FString GeneratedClass; + if (AssetData.GetTagValue(GeneratedClassTagName, GeneratedClass) && !GeneratedClass.IsEmpty()) + { + // The tag is stored in export-text form, e.g. BlueprintGeneratedClass'/Game/X/BP_Y.BP_Y_C'. + // TrySetPath rather than the FStringView constructor: a malformed tag then leaves the + // path empty instead of asserting mid-sweep. + Node.GeneratedClassPath.TrySetPath(FPackageName::ExportTextPathToObjectPath(GeneratedClass)); + } + + return true; + }, UE::AssetRegistry::EEnumerateAssetsFlags::OnlyOnDiskAssets); + + Stats.EnumerateSeconds = FPlatformTime::Seconds() - StartTime; + } + + Stats.NumPackages = Nodes.Num(); + + // --- Pass 2: dependency edges ------------------------------------------------------------ + DependencyOffsets.SetNumZeroed(Nodes.Num() + 1); + + if (Options.bGatherDependencies) + { + const double StartTime = FPlatformTime::Seconds(); + + TArray EdgeBuffer; + + // Node count grows as we discover dependency targets outside the swept paths (engine + // content, plugin content). Index by position rather than caching Num(). + for (int32 NodeIndex = 0; NodeIndex < Nodes.Num(); ++NodeIndex) + { + DependencyOffsets[NodeIndex] = Dependencies.Num(); + + EdgeBuffer.Reset(); + + // The query that makes this tool correct. Package category, no requirement flags. + // Never EDependencyQuery::Hard: map -> external actor edges carry Game|Build without + // Hard, so a Hard query silently drops every OFPA actor in the project. + AssetRegistry.GetDependencies( + Nodes[NodeIndex].PackageName, + EdgeBuffer, + AssetUsageAudit::MakeTraversalCategory(), + AssetUsageAudit::MakeTraversalQuery()); + + for (const FAssetDependency& Dependency : EdgeBuffer) + { + const FName TargetPackage = Dependency.AssetId.PackageName; + + if (TargetPackage.IsNone() || TargetPackage == Nodes[NodeIndex].PackageName) + { + continue; + } + + if (AssetUsagePaths::IsPathExcluded(TargetPackage, Options.ExcludePackagePaths)) + { + continue; + } + + // Script packages (/Script/Engine) are code, not assets. They inflate the graph + // and can never be exported or reported as unused. + if (AssetUsagePaths::IsScriptPackage(TargetPackage)) + { + continue; + } + + FAssetUsageEdge& Edge = Dependencies.AddDefaulted_GetRef(); + Edge.TargetIndex = AddOrFindPackage(TargetPackage); + Edge.Properties = Dependency.Properties; + } + + // AddOrFindPackage above may have appended nodes; keep the offset table in step. + if (DependencyOffsets.Num() < Nodes.Num() + 1) + { + DependencyOffsets.SetNumZeroed(Nodes.Num() + 1, EAllowShrinking::No); + } + } + + DependencyOffsets[Nodes.Num()] = Dependencies.Num(); + + Stats.DependencySeconds = FPlatformTime::Seconds() - StartTime; + } + else + { + DependencyOffsets[Nodes.Num()] = 0; + } + + // Nodes discovered during pass 2 have no inventory data. Fill in what we can from the path so + // they are never reported as an unnamed blank row. + for (FAssetUsageNode& Node : Nodes) + { + if (Node.AssetName.IsNone()) + { + Node.AssetName = FName(*FPackageName::GetShortName(Node.PackageName)); + } + } + + for (int32 NodeIndex = 0; NodeIndex < Nodes.Num(); ++NodeIndex) + { + if (Nodes[NodeIndex].bIsLevel) + { + LevelIndices.Add(NodeIndex); + } + + if (Nodes[NodeIndex].bIsExternalPackage) + { + ++Stats.NumExternalPackages; + } + } + + BuildReverseEdges(); + + Stats.NumPackages = Nodes.Num(); + Stats.NumEdges = Dependencies.Num(); + Stats.NumLevels = LevelIndices.Num(); + + UE_LOG(LogAssetUsageAudit, Log, + TEXT("Graph built: %d packages, %d edges, %d levels, %d external packages. Enumerate %.2fs, dependencies %.2fs, total %.2fs."), + Stats.NumPackages, Stats.NumEdges, Stats.NumLevels, Stats.NumExternalPackages, + Stats.EnumerateSeconds, Stats.DependencySeconds, Stats.TotalSeconds()); +} + +void FAssetUsageGraph::BuildReverseEdges() +{ + Referencers.Reset(); + ReferencerOffsets.Reset(); + ReferencerOffsets.SetNumZeroed(Nodes.Num() + 1); + + if (Dependencies.IsEmpty()) + { + return; + } + + // Counting sort: one pass to size each bucket, one to fill. Avoids per-node TArray growth. + for (const FAssetUsageEdge& Edge : Dependencies) + { + if (Nodes.IsValidIndex(Edge.TargetIndex)) + { + ++ReferencerOffsets[Edge.TargetIndex + 1]; + } + } + + for (int32 Index = 1; Index <= Nodes.Num(); ++Index) + { + ReferencerOffsets[Index] += ReferencerOffsets[Index - 1]; + } + + Referencers.SetNumUninitialized(Dependencies.Num()); + + TArray FillCursor = ReferencerOffsets; + + for (int32 SourceIndex = 0; SourceIndex < Nodes.Num(); ++SourceIndex) + { + for (const FAssetUsageEdge& Edge : GetDependencies(SourceIndex)) + { + if (!Nodes.IsValidIndex(Edge.TargetIndex)) + { + continue; + } + + FAssetUsageEdge& Reverse = Referencers[FillCursor[Edge.TargetIndex]++]; + Reverse.TargetIndex = SourceIndex; + Reverse.Properties = Edge.Properties; + } + } +} + +TConstArrayView FAssetUsageGraph::GetDependencies(int32 Index) const +{ + if (!Nodes.IsValidIndex(Index) || DependencyOffsets.Num() < Nodes.Num() + 1) + { + return TConstArrayView(); + } + + const int32 Begin = DependencyOffsets[Index]; + const int32 End = DependencyOffsets[Index + 1]; + + if (Begin >= End) + { + return TConstArrayView(); + } + + return TConstArrayView(Dependencies.GetData() + Begin, End - Begin); +} + +TConstArrayView FAssetUsageGraph::GetReferencers(int32 Index) const +{ + if (!Nodes.IsValidIndex(Index) || ReferencerOffsets.Num() < Nodes.Num() + 1) + { + return TConstArrayView(); + } + + const int32 Begin = ReferencerOffsets[Index]; + const int32 End = ReferencerOffsets[Index + 1]; + + if (Begin >= End) + { + return TConstArrayView(); + } + + return TConstArrayView(Referencers.GetData() + Begin, End - Begin); +} + +int32 FAssetUsageGraph::ResolveRedirector(int32 Index) const +{ + int32 Current = Index; + + for (int32 Hop = 0; Hop < MaxRedirectorHops; ++Hop) + { + if (!Nodes.IsValidIndex(Current) || !Nodes[Current].bIsRedirector) + { + return Current; + } + + // A redirector package depends on exactly the asset it forwards to. Take the first + // non-self dependency; there is no second candidate in a well-formed redirector. + const TConstArrayView Forward = GetDependencies(Current); + + int32 Next = INDEX_NONE; + for (const FAssetUsageEdge& Edge : Forward) + { + if (Edge.TargetIndex != Current && Nodes.IsValidIndex(Edge.TargetIndex)) + { + Next = Edge.TargetIndex; + break; + } + } + + if (Next == INDEX_NONE) + { + // Dangling redirector: points at something outside the sweep, or at nothing. + return Current; + } + + Current = Next; + } + + UE_LOG(LogAssetUsageAudit, Warning, + TEXT("Redirector chain from '%s' exceeded %d hops; treating it as unresolvable."), + *Nodes[Index].PackageName.ToString(), MaxRedirectorHops); + + return Current; +} diff --git a/Source/AssetUsageAuditCore/Private/AssetUsagePaths.cpp b/Source/AssetUsageAuditCore/Private/AssetUsagePaths.cpp new file mode 100644 index 0000000..3aa2959 --- /dev/null +++ b/Source/AssetUsageAuditCore/Private/AssetUsagePaths.cpp @@ -0,0 +1,186 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetUsagePaths.h" +#include "HAL/FileManager.h" +#include "Misc/Paths.h" +#include "Misc/StringBuilder.h" +#include "String/Find.h" + +namespace AssetUsagePaths +{ + namespace Private + { + // These two literals match FPackagePath's folder names (CoreUObject PackagePath.cpp). + // They are used only to CLASSIFY an existing path. Producing an external-actor path is + // ULevel::GetExternalActorsPaths's job, because that form has /CB/ and /EDL/ variants and + // is extensible by plugin delegates - string building it here would be silently wrong. + static const TCHAR* ExternalActorsFolder = TEXT("__ExternalActors__"); + static const TCHAR* ExternalObjectsFolder = TEXT("__ExternalObjects__"); + + static const TCHAR* GameRoot = TEXT("/Game"); + static const TCHAR* ContentPrefix = TEXT("Content"); + + /** Prefix match that only accepts a whole path segment, so /Game/Art misses /Game/ArtSource. */ + static bool StartsWithSegment(FStringView Path, FStringView Prefix) + { + if (Prefix.IsEmpty() || Path.Len() < Prefix.Len()) + { + return false; + } + + if (!Path.Left(Prefix.Len()).Equals(Prefix, ESearchCase::IgnoreCase)) + { + return false; + } + + return Path.Len() == Prefix.Len() || Path[Prefix.Len()] == TEXT('/'); + } + } + + bool IsExternalPackage(FStringView PackagePath) + { + return UE::String::FindFirst(PackagePath, Private::ExternalActorsFolder, ESearchCase::IgnoreCase) != INDEX_NONE + || UE::String::FindFirst(PackagePath, Private::ExternalObjectsFolder, ESearchCase::IgnoreCase) != INDEX_NONE; + } + + bool IsExternalPackage(FName PackageName) + { + TStringBuilder<256> Builder; + PackageName.ToString(Builder); + return IsExternalPackage(Builder.ToView()); + } + + bool IsScriptPackage(FStringView PackagePath) + { + return Private::StartsWithSegment(PackagePath, TEXTVIEW("/Script")); + } + + bool IsScriptPackage(FName PackageName) + { + TStringBuilder<256> Builder; + PackageName.ToString(Builder); + return IsScriptPackage(Builder.ToView()); + } + + bool IsEngineOrTempPackage(FStringView PackagePath) + { + return Private::StartsWithSegment(PackagePath, TEXTVIEW("/Engine")) + || Private::StartsWithSegment(PackagePath, TEXTVIEW("/Temp")); + } + + FString NormalizeExclusionPrefix(const FString& Prefix) + { + FString Working = Prefix; + Working.TrimStartAndEndInline(); + Working.ReplaceInline(TEXT("\\"), TEXT("/"), ESearchCase::CaseSensitive); + + while (Working.EndsWith(TEXT("/"), ESearchCase::CaseSensitive)) + { + Working.LeftChopInline(1, EAllowShrinking::No); + } + + if (Working.IsEmpty()) + { + return FString(); + } + + // Content-relative form, which is what the UI shows and what settings files accumulate. + if (Private::StartsWithSegment(Working, Private::ContentPrefix)) + { + const FString Remainder = Working.RightChop(FCString::Strlen(Private::ContentPrefix)); + return FString(Private::GameRoot) + Remainder; + } + + if (!Working.StartsWith(TEXT("/"), ESearchCase::CaseSensitive)) + { + Working.InsertAt(0, TEXT("/")); + } + + return Working; + } + + bool IsPathExcluded(FStringView PackagePath, const TArray& ExcludedPrefixes) + { + for (const FString& RawPrefix : ExcludedPrefixes) + { + const FString Normalized = NormalizeExclusionPrefix(RawPrefix); + if (!Normalized.IsEmpty() && Private::StartsWithSegment(PackagePath, Normalized)) + { + return true; + } + } + + return false; + } + + bool IsPathExcluded(FName PackageName, const TArray& ExcludedPrefixes) + { + if (ExcludedPrefixes.IsEmpty()) + { + return false; + } + + TStringBuilder<256> Builder; + PackageName.ToString(Builder); + return IsPathExcluded(Builder.ToView(), ExcludedPrefixes); + } + + FString ToProjectRelativePath(FName PackageName, bool bIsLevel) + { + TStringBuilder<256> Builder; + PackageName.ToString(Builder); + const FStringView PackagePath = Builder.ToView(); + + if (!Private::StartsWithSegment(PackagePath, Private::GameRoot)) + { + // /Script, /Engine and plugin mounts have no project-relative form. Reporting an + // invented path for them would send an artist looking for a file that is not there. + return FString(); + } + + const FStringView Remainder = PackagePath.RightChop(FCString::Strlen(Private::GameRoot)); + const TCHAR* Extension = bIsLevel ? TEXT(".umap") : TEXT(".uasset"); + + return FString::Printf(TEXT("Content%.*s%s"), Remainder.Len(), Remainder.GetData(), Extension); + } + + FString ValidateMigrateDestination(const FString& Directory) + { + if (Directory.IsEmpty()) + { + return TEXT("Choose the destination project's Content folder."); + } + + FString Normalised = Directory; + FPaths::NormalizeDirectoryName(Normalised); + Normalised += TEXT("/"); + + if (!Normalised.EndsWith(TEXT("/Content/"))) + { + return TEXT("Migrate needs a folder named Content - pick the destination project's Content folder itself, not the project root."); + } + + // Second rule. A folder merely named Content passes the first check and Migrate still + // aborts: the destination mount point is derived from what sits beside it. + const FString Parent = Normalised + TEXT("../"); + + TArray Found; + IFileManager::Get().FindFiles(Found, *Parent, TEXT("uproject")); + + if (!Found.IsEmpty()) + { + return FString(); + } + + IFileManager::Get().FindFiles(Found, *Parent, TEXT("uplugin")); + + if (Found.Num() == 1) + { + return FString(); + } + + return Found.Num() > 1 + ? TEXT("That folder's parent holds several .uplugin files, so Unreal cannot tell which mount point to migrate into.") + : TEXT("No .uproject or .uplugin next to that Content folder, so Unreal has no mount point to migrate into."); + } +} diff --git a/Source/AssetUsageAuditCore/Private/AssetUsageReportWriter.cpp b/Source/AssetUsageAuditCore/Private/AssetUsageReportWriter.cpp new file mode 100644 index 0000000..65c0903 --- /dev/null +++ b/Source/AssetUsageAuditCore/Private/AssetUsageReportWriter.cpp @@ -0,0 +1,311 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetUsageReportWriter.h" +#include "AssetUsageAuditCoreModule.h" +#include "AssetUsageAuditor.h" +#include "AssetUsageAuditTypes.h" +#include "HAL/FileManager.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" +#include "Serialization/JsonSerializer.h" +#include "Serialization/JsonWriter.h" + +namespace +{ + /** Column order is shared by the CSV header and the row writer so they cannot drift. */ + const TCHAR* CsvColumns[] = { + TEXT("Type"), + TEXT("GeneratedClass"), + TEXT("Name"), + TEXT("PathFromProjectRoot"), + TEXT("Verdict"), + TEXT("LevelCount"), + TEXT("Levels"), + TEXT("HardRefs"), + TEXT("SoftRefs"), + TEXT("Provenance"), + TEXT("Route"), + TEXT("ProvenanceDetail") + }; + + FString JoinNames(const TArray& Names, const FString& Separator) + { + TArray Strings; + Strings.Reserve(Names.Num()); + + for (const FName& Name : Names) + { + Strings.Add(Name.ToString()); + } + + return FString::Join(Strings, *Separator); + } +} + +FString FAssetUsageReportWriter::EscapeCsvField(const FString& Field, TCHAR Delimiter) +{ + const bool bNeedsQuoting = + Field.Contains(FString(1, &Delimiter), ESearchCase::CaseSensitive) || + Field.Contains(TEXT("\""), ESearchCase::CaseSensitive) || + Field.Contains(TEXT("\n"), ESearchCase::CaseSensitive) || + Field.Contains(TEXT("\r"), ESearchCase::CaseSensitive); + + if (!bNeedsQuoting) + { + return Field; + } + + FString Escaped = Field; + Escaped.ReplaceInline(TEXT("\""), TEXT("\"\""), ESearchCase::CaseSensitive); + + return FString::Printf(TEXT("\"%s\""), *Escaped); +} + +FString FAssetUsageReportWriter::BuildCsv(const FAssetUsageAuditResult& AuditResult, const FOptions& Options) +{ + const FAssetUsageReportHeader& Header = AuditResult.Header; + const TCHAR Delimiter = Options.CsvDelimiter; + + FString Output; + Output.Reserve(AuditResult.Rows.Num() * 160 + 2048); + + // Provenance of the report itself, as '#' comment lines. Excel shows them as ordinary rows, + // which is fine and is much better than a report nobody can date or attribute to a filter set. + Output += FString::Printf(TEXT("# Asset Usage Audit report\n")); + Output += FString::Printf(TEXT("# Generated: %s\n"), *Header.GeneratedAt.ToIso8601()); + Output += FString::Printf(TEXT("# Project: %s\n"), *Header.ProjectName); + Output += FString::Printf(TEXT("# Engine: %s\n"), *Header.EngineVersion); + Output += FString::Printf(TEXT("# Tool: %s\n"), *Header.ToolVersion); + Output += FString::Printf(TEXT("# Levels scanned: %d\n"), Header.LevelsScanned); + Output += FString::Printf(TEXT("# Assets scanned: %d\n"), Header.AssetsScanned); + Output += FString::Printf(TEXT("# Rows reported: %d\n"), AuditResult.Rows.Num()); + Output += FString::Printf(TEXT("# Scan duration: %.2f s\n"), Header.ScanDurationSeconds); + + for (const FString& Filter : Header.AppliedFilters) + { + Output += FString::Printf(TEXT("# Filter: %s\n"), *Filter); + } + + Output += FString::Printf(TEXT("# Verdict counts: UsedOnLevel=%d UsedByAssetsOnly=%d ReferencedFromConfigOrSource=%d Unreferenced=%d Unknown=%d\n"), + AuditResult.CountByVerdict(EAssetUsageVerdict::UsedOnLevel), + AuditResult.CountByVerdict(EAssetUsageVerdict::UsedByAssetsOnly), + AuditResult.CountByVerdict(EAssetUsageVerdict::ReferencedFromConfigOrSource), + AuditResult.CountByVerdict(EAssetUsageVerdict::Unreferenced), + AuditResult.CountByVerdict(EAssetUsageVerdict::Unknown)); + + Output += TEXT("# Unknown means the registry cannot see this asset's references (FMOD, DataTable rows,\n"); + Output += TEXT("# runtime-built paths). It does NOT mean unused. Never delete on the strength of it.\n"); + + for (int32 ColumnIndex = 0; ColumnIndex < UE_ARRAY_COUNT(CsvColumns); ++ColumnIndex) + { + if (ColumnIndex > 0) + { + Output.AppendChar(Delimiter); + } + + Output += CsvColumns[ColumnIndex]; + } + + Output += TEXT("\n"); + + for (const FAssetUsageRow& Row : AuditResult.Rows) + { + TArray> Fields; + + Fields.Add(Row.ClassPath.IsNull() ? FString() : Row.ClassPath.ToString()); + Fields.Add(Row.GeneratedClassPath.IsNull() ? FString() : Row.GeneratedClassPath.ToString()); + Fields.Add(Row.AssetName.ToString()); + Fields.Add(Row.PathFromProjectRoot); + Fields.Add(LexToString(Row.Verdict)); + Fields.Add(FString::FromInt(Row.Levels.Num())); + Fields.Add(JoinNames(Row.Levels, Options.CsvMultiValueSeparator)); + Fields.Add(FString::FromInt(Row.HardReferenceCount)); + Fields.Add(FString::FromInt(Row.SoftReferenceCount)); + Fields.Add(ProvenanceToString(Row.Provenance)); + Fields.Add(Row.Route); + Fields.Add(Row.ProvenanceDetail); + + checkf(Fields.Num() == UE_ARRAY_COUNT(CsvColumns), TEXT("CSV row width must match the header.")); + + for (int32 FieldIndex = 0; FieldIndex < Fields.Num(); ++FieldIndex) + { + if (FieldIndex > 0) + { + Output.AppendChar(Delimiter); + } + + Output += EscapeCsvField(Fields[FieldIndex], Delimiter); + } + + Output += TEXT("\n"); + } + + return Output; +} + +FString FAssetUsageReportWriter::BuildJson(const FAssetUsageAuditResult& AuditResult) +{ + const FAssetUsageReportHeader& Header = AuditResult.Header; + + FString Output; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&Output); + + Writer->WriteObjectStart(); + + Writer->WriteObjectStart(TEXT("header")); + Writer->WriteValue(TEXT("generatedAt"), Header.GeneratedAt.ToIso8601()); + Writer->WriteValue(TEXT("project"), Header.ProjectName); + Writer->WriteValue(TEXT("engineVersion"), Header.EngineVersion); + Writer->WriteValue(TEXT("toolVersion"), Header.ToolVersion); + Writer->WriteValue(TEXT("levelsScanned"), Header.LevelsScanned); + Writer->WriteValue(TEXT("assetsScanned"), Header.AssetsScanned); + Writer->WriteValue(TEXT("rowsReported"), AuditResult.Rows.Num()); + Writer->WriteValue(TEXT("scanDurationSeconds"), Header.ScanDurationSeconds); + + Writer->WriteArrayStart(TEXT("appliedFilters")); + for (const FString& Filter : Header.AppliedFilters) + { + Writer->WriteValue(Filter); + } + Writer->WriteArrayEnd(); + + Writer->WriteObjectEnd(); + + Writer->WriteObjectStart(TEXT("verdictCounts")); + Writer->WriteValue(TEXT("usedOnLevel"), AuditResult.CountByVerdict(EAssetUsageVerdict::UsedOnLevel)); + Writer->WriteValue(TEXT("usedByAssetsOnly"), AuditResult.CountByVerdict(EAssetUsageVerdict::UsedByAssetsOnly)); + Writer->WriteValue(TEXT("referencedFromConfigOrSource"), AuditResult.CountByVerdict(EAssetUsageVerdict::ReferencedFromConfigOrSource)); + Writer->WriteValue(TEXT("unreferenced"), AuditResult.CountByVerdict(EAssetUsageVerdict::Unreferenced)); + Writer->WriteValue(TEXT("unknown"), AuditResult.CountByVerdict(EAssetUsageVerdict::Unknown)); + Writer->WriteObjectEnd(); + + Writer->WriteObjectStart(TEXT("graphStats")); + Writer->WriteValue(TEXT("packages"), AuditResult.GraphStats.NumPackages); + Writer->WriteValue(TEXT("edges"), AuditResult.GraphStats.NumEdges); + Writer->WriteValue(TEXT("levels"), AuditResult.GraphStats.NumLevels); + Writer->WriteValue(TEXT("externalPackages"), AuditResult.GraphStats.NumExternalPackages); + Writer->WriteValue(TEXT("enumerateSeconds"), AuditResult.GraphStats.EnumerateSeconds); + Writer->WriteValue(TEXT("dependencySeconds"), AuditResult.GraphStats.DependencySeconds); + Writer->WriteObjectEnd(); + + Writer->WriteArrayStart(TEXT("assets")); + + for (const FAssetUsageRow& Row : AuditResult.Rows) + { + Writer->WriteObjectStart(); + Writer->WriteValue(TEXT("name"), Row.AssetName.ToString()); + Writer->WriteValue(TEXT("package"), Row.PackageName.ToString()); + Writer->WriteValue(TEXT("path"), Row.PathFromProjectRoot); + Writer->WriteValue(TEXT("type"), Row.ClassPath.IsNull() ? FString() : Row.ClassPath.ToString()); + + if (!Row.GeneratedClassPath.IsNull()) + { + Writer->WriteValue(TEXT("generatedClass"), Row.GeneratedClassPath.ToString()); + } + + Writer->WriteValue(TEXT("verdict"), LexToString(Row.Verdict)); + Writer->WriteValue(TEXT("provenance"), ProvenanceToString(Row.Provenance)); + Writer->WriteValue(TEXT("hardReferenceCount"), Row.HardReferenceCount); + Writer->WriteValue(TEXT("softReferenceCount"), Row.SoftReferenceCount); + + Writer->WriteArrayStart(TEXT("levels")); + for (const FName& Level : Row.Levels) + { + Writer->WriteValue(Level.ToString()); + } + Writer->WriteArrayEnd(); + + if (!Row.Route.IsEmpty()) + { + Writer->WriteValue(TEXT("route"), Row.Route); + } + + if (!Row.ProvenanceDetail.IsEmpty()) + { + Writer->WriteValue(TEXT("provenanceDetail"), Row.ProvenanceDetail); + } + + Writer->WriteObjectEnd(); + } + + Writer->WriteArrayEnd(); + Writer->WriteObjectEnd(); + Writer->Close(); + + return Output; +} + +FAssetUsageReportWriter::FResult FAssetUsageReportWriter::Write(const FAssetUsageAuditResult& AuditResult, const FOptions& Options) +{ + FResult Result; + + if (Options.OutputDirectory.IsEmpty()) + { + Result.ErrorMessage = TEXT("Output directory is empty."); + return Result; + } + + if (!IFileManager::Get().MakeDirectory(*Options.OutputDirectory, true)) + { + if (!IFileManager::Get().DirectoryExists(*Options.OutputDirectory)) + { + Result.ErrorMessage = FString::Printf(TEXT("Could not create output directory '%s'."), *Options.OutputDirectory); + return Result; + } + } + + const FString BaseName = Options.BaseFileName.IsEmpty() ? TEXT("AssetUsageReport") : Options.BaseFileName; + + bool bAnyWritten = false; + + if (Options.bWriteJson) + { + const FString JsonPath = FPaths::Combine(Options.OutputDirectory, BaseName + TEXT(".json")); + + if (FFileHelper::SaveStringToFile(BuildJson(AuditResult), *JsonPath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) + { + Result.JsonPath = JsonPath; + bAnyWritten = true; + } + else + { + Result.ErrorMessage = FString::Printf(TEXT("Failed to write '%s'."), *JsonPath); + return Result; + } + } + + if (Options.bWriteCsv) + { + const FString CsvPath = FPaths::Combine(Options.OutputDirectory, BaseName + TEXT(".csv")); + + // Excel needs the BOM to read UTF-8; without it Cyrillic asset names come out mangled. + const FFileHelper::EEncodingOptions Encoding = Options.bCsvUtf8Bom + ? FFileHelper::EEncodingOptions::ForceUTF8 + : FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM; + + if (FFileHelper::SaveStringToFile(BuildCsv(AuditResult, Options), *CsvPath, Encoding)) + { + Result.CsvPath = CsvPath; + bAnyWritten = true; + } + else + { + Result.ErrorMessage = FString::Printf(TEXT("Failed to write '%s'."), *CsvPath); + return Result; + } + } + + Result.bSuccess = bAnyWritten; + + if (!bAnyWritten) + { + Result.ErrorMessage = TEXT("Both JSON and CSV output were disabled."); + } + else + { + UE_LOG(LogAssetUsageAudit, Log, TEXT("Report written: %d rows. JSON '%s', CSV '%s'."), + AuditResult.Rows.Num(), *Result.JsonPath, *Result.CsvPath); + } + + return Result; +} diff --git a/Source/AssetUsageAuditCore/Private/IndirectReferenceScanner.cpp b/Source/AssetUsageAuditCore/Private/IndirectReferenceScanner.cpp new file mode 100644 index 0000000..f95e736 --- /dev/null +++ b/Source/AssetUsageAuditCore/Private/IndirectReferenceScanner.cpp @@ -0,0 +1,161 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "IndirectReferenceScanner.h" +#include "AssetUsageAuditCoreModule.h" +#include "HAL/FileManager.h" +#include "HAL/PlatformFileManager.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" +#include "Misc/StringBuilder.h" + +namespace +{ + const TCHAR* GameRootLiteral = TEXT("/Game/"); + + /** + * Characters that can legally continue a package path. Everything else ends the literal. + * + * Note '.' is included so that an object path like /Game/X/BP_Y.BP_Y_C is captured whole and + * then trimmed below; stopping at the dot would work too but would silently accept the + * malformed tail of a truncated literal. + */ + bool IsPathChar(TCHAR Char) + { + return FChar::IsAlnum(Char) || Char == TEXT('_') || Char == TEXT('/') || Char == TEXT('.') || Char == TEXT('-'); + } +} + +FString FIndirectReference::ToProvenanceString() const +{ + return FString::Printf(TEXT("%s:%d"), *FPaths::GetCleanFilename(SourceFile), LineNumber); +} + +void FIndirectReferenceScanner::ExtractGamePathsFromLine(FStringView Line, TArray& OutPackageNames) +{ + const int32 RootLen = FCString::Strlen(GameRootLiteral); + + int32 SearchStart = 0; + while (SearchStart < Line.Len()) + { + const FStringView Remaining = Line.RightChop(SearchStart); + const int32 Found = UE::String::FindFirst(Remaining, GameRootLiteral, ESearchCase::IgnoreCase); + if (Found == INDEX_NONE) + { + return; + } + + const int32 Start = SearchStart + Found; + + int32 End = Start + RootLen; + while (End < Line.Len() && IsPathChar(Line[End])) + { + ++End; + } + + FString Candidate(Line.Mid(Start, End - Start)); + SearchStart = End > Start ? End : Start + RootLen; + + // "/Game/" alone, or a trailing slash, is not a reference. + if (Candidate.Len() <= RootLen) + { + continue; + } + + while (Candidate.EndsWith(TEXT("/"), ESearchCase::CaseSensitive) || Candidate.EndsWith(TEXT("."), ESearchCase::CaseSensitive)) + { + Candidate.LeftChopInline(1, EAllowShrinking::No); + } + + // Object path -> package path. "/Game/X/BP_Y.BP_Y_C" and "/Game/X/T.T" both name package + // "/Game/X/...". Take everything before the first dot after the last slash. + int32 LastSlash = INDEX_NONE; + Candidate.FindLastChar(TEXT('/'), LastSlash); + + int32 DotAfterSlash = INDEX_NONE; + if (Candidate.FindChar(TEXT('.'), DotAfterSlash) && DotAfterSlash > LastSlash) + { + Candidate.LeftInline(DotAfterSlash, EAllowShrinking::No); + } + + if (Candidate.Len() <= RootLen) + { + continue; + } + + OutPackageNames.AddUnique(Candidate); + } +} + +FIndirectReferenceScanner::FOptions FIndirectReferenceScanner::MakeDefaultOptions() +{ + FOptions Options; + + const FString ProjectDir = FPaths::ConvertRelativePathToFull(FPaths::ProjectDir()); + Options.Directories.Add(ProjectDir / TEXT("Config")); + Options.Directories.Add(ProjectDir / TEXT("Source")); + + return Options; +} + +TArray FIndirectReferenceScanner::Scan(const FOptions& Options) +{ + TArray Results; + + IFileManager& FileManager = IFileManager::Get(); + + for (const FString& Directory : Options.Directories) + { + if (!FileManager.DirectoryExists(*Directory)) + { + UE_LOG(LogAssetUsageAudit, Verbose, TEXT("Indirect scan: directory '%s' does not exist, skipping."), *Directory); + continue; + } + + const bool bIsConfigDir = Directory.EndsWith(TEXT("Config"), ESearchCase::IgnoreCase); + + TArray FoundFiles; + FileManager.FindFilesRecursive(FoundFiles, *Directory, TEXT("*.*"), true, false); + + for (const FString& FilePath : FoundFiles) + { + const FString Extension = FPaths::GetExtension(FilePath, true).ToLower(); + if (!Options.Extensions.Contains(Extension)) + { + continue; + } + + if (FileManager.FileSize(*FilePath) > Options.MaxFileSizeBytes) + { + UE_LOG(LogAssetUsageAudit, Verbose, TEXT("Indirect scan: skipping oversized file '%s'."), *FilePath); + continue; + } + + TArray Lines; + if (!FFileHelper::LoadFileToStringArray(Lines, *FilePath)) + { + UE_LOG(LogAssetUsageAudit, Warning, TEXT("Indirect scan: could not read '%s'."), *FilePath); + continue; + } + + TArray PackageNames; + for (int32 LineIndex = 0; LineIndex < Lines.Num(); ++LineIndex) + { + PackageNames.Reset(); + ExtractGamePathsFromLine(Lines[LineIndex], PackageNames); + + for (const FString& PackageName : PackageNames) + { + FIndirectReference& Reference = Results.AddDefaulted_GetRef(); + Reference.PackageName = FName(*PackageName); + Reference.SourceFile = FilePath; + Reference.LineNumber = LineIndex + 1; + Reference.bFromConfig = bIsConfigDir; + } + } + } + } + + UE_LOG(LogAssetUsageAudit, Log, TEXT("Indirect scan found %d /Game path literals in Config and Source."), Results.Num()); + + return Results; +} diff --git a/Source/AssetUsageAuditCore/Private/LevelUsageResolver.cpp b/Source/AssetUsageAuditCore/Private/LevelUsageResolver.cpp new file mode 100644 index 0000000..76b609f --- /dev/null +++ b/Source/AssetUsageAuditCore/Private/LevelUsageResolver.cpp @@ -0,0 +1,419 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "LevelUsageResolver.h" + +#include "AssetUsageAuditCoreModule.h" +#include "AssetUsageGraph.h" +#include "AssetRegistry/ARFilter.h" +#include "AssetRegistry/IAssetRegistry.h" +#include "Algo/Reverse.h" +#include "Engine/Level.h" +#include "HAL/PlatformTime.h" +#include "Misc/PackageName.h" +#include "Misc/StringBuilder.h" + +FLevelUsageResolver::FLevelUsageResolver(const FAssetUsageGraph& InGraph, IAssetRegistry& InAssetRegistry) + : Graph(InGraph) + , AssetRegistry(InAssetRegistry) +{ +} + +TArray FLevelUsageResult::GetLevelsForAsset(int32 AssetIndex) const +{ + TArray Out; + + for (int32 LevelIdx = 0; LevelIdx < LevelReachability.Num(); ++LevelIdx) + { + const TBitArray<>& Bits = LevelReachability[LevelIdx]; + if (Bits.IsValidIndex(AssetIndex) && Bits[AssetIndex]) + { + Out.Add(LevelPackageNames[LevelIdx]); + } + } + + return Out; +} + +FLevelUsageResult FLevelUsageResolver::Resolve(const FLevelUsageResolveOptions& Options) +{ + FLevelUsageResult Result; + + const int32 NumPackages = Graph.Num(); + if (NumPackages == 0) + { + return Result; + } + + // Decide which levels to walk. An explicit list is resolved against the graph so a typo + // surfaces as a warning rather than as a silently empty report. + TArray LevelIndices; + if (Options.LevelPackages.Num() > 0) + { + LevelIndices.Reserve(Options.LevelPackages.Num()); + for (FName LevelPackage : Options.LevelPackages) + { + const int32 Index = Graph.FindPackageIndex(LevelPackage); + if (Index == INDEX_NONE) + { + UE_LOG(LogAssetUsageAudit, Warning, TEXT("Level '%s' is not in the graph; skipping."), *LevelPackage.ToString()); + continue; + } + + if (!Graph.GetNode(Index).bIsLevel) + { + UE_LOG(LogAssetUsageAudit, Warning, TEXT("Package '%s' is not a World asset; skipping."), *LevelPackage.ToString()); + continue; + } + + LevelIndices.Add(Index); + } + } + else + { + LevelIndices = Graph.GetLevelIndices(); + } + + const int32 NumLevels = LevelIndices.Num(); + + UE_LOG(LogAssetUsageAudit, Log, TEXT("Resolving %d level(s); %d requested explicitly, %d present in the graph."), + NumLevels, Options.LevelPackages.Num(), Graph.GetLevelIndices().Num()); + + Result.LevelPackageNames.Reserve(NumLevels); + Result.LevelReachability.Reserve(NumLevels); + Result.ReachableFromAnyLevel.Init(false, NumPackages); + Result.Provenance.Init(EAssetUsageProvenance::None, NumPackages); + Result.HardReferenceCounts.Init(0, NumPackages); + Result.SoftReferenceCounts.Init(0, NumPackages); + + // Scratch buffers reused across every level so the BFS allocates nothing per level. + TArray ScratchPredecessor; + TArray ScratchQueue; + ScratchPredecessor.SetNumUninitialized(NumPackages); + ScratchQueue.Reserve(FMath::Min(NumPackages, 4096)); + + const double TraversalStart = FPlatformTime::Seconds(); + + for (int32 Ordinal = 0; Ordinal < NumLevels; ++Ordinal) + { + if (Options.ShouldAbort && Options.ShouldAbort()) + { + UE_LOG(LogAssetUsageAudit, Warning, TEXT("Level sweep aborted after %d of %d levels."), Ordinal, NumLevels); + break; + } + + if (Options.OnLevelProgress) + { + Options.OnLevelProgress(Ordinal, NumLevels); + } + + const int32 LevelIndex = LevelIndices[Ordinal]; + + // The gatherer only reports external packages the registry has already seen. Without this + // a level nobody opened this session can report zero actors, which is indistinguishable + // from a level that genuinely has none. + if (Options.bScanLevelAssetsFirst) + { + const double ScanStart = FPlatformTime::Seconds(); + ULevel::ScanLevelAssets(Graph.GetNode(LevelIndex).PackageName.ToString()); + Result.Stats.ScanLevelAssetsSeconds += FPlatformTime::Seconds() - ScanStart; + } + + TBitArray<> Reachable; + Reachable.Init(false, NumPackages); + + TraverseLevel(LevelIndex, Options, Reachable, Result, ScratchPredecessor, ScratchQueue); + + Result.LevelPackageNames.Add(Graph.GetNode(LevelIndex).PackageName); + Result.LevelReachability.Add(MoveTemp(Reachable)); + ++Result.Stats.LevelsScanned; + } + + Result.Stats.TraversalSeconds = FPlatformTime::Seconds() - TraversalStart - Result.Stats.ScanLevelAssetsSeconds; + + for (int32 Index = 0; Index < NumPackages; ++Index) + { + if (Result.ReachableFromAnyLevel[Index]) + { + ++Result.Stats.AssetsReachable; + } + } + + return Result; +} + +void FLevelUsageResolver::TraverseLevel(int32 LevelIndex, const FLevelUsageResolveOptions& Options, TBitArray<>& OutReachable, FLevelUsageResult& InOutResult, TArray& ScratchPredecessor, TArray& ScratchQueue) +{ + const int32 NumPackages = Graph.Num(); + + if (Options.bRecordRoutes) + { + for (int32& Pred : ScratchPredecessor) + { + Pred = INDEX_NONE; + } + } + + ScratchQueue.Reset(); + + const auto Enqueue = [&](int32 Index, int32 FromIndex, EAssetUsageProvenance EdgeProvenance) + { + if (!Graph.IsValidIndex(Index) || OutReachable[Index]) + { + return; + } + + OutReachable[Index] = true; + InOutResult.ReachableFromAnyLevel[Index] = true; + InOutResult.Provenance[Index] |= EdgeProvenance; + + if (Options.bRecordRoutes) + { + ScratchPredecessor[Index] = FromIndex; + } + + ScratchQueue.Add(Index); + }; + + Enqueue(LevelIndex, INDEX_NONE, EAssetUsageProvenance::None); + + // Explicit OFPA seeds. Normally redundant with the gatherer's edges, but it costs one registry + // filter per level and converts a stale-registry failure from wrong into merely slow. + if (Options.bSeedExternalPackages) + { + TArray Seeds; + GatherExternalPackageSeeds(LevelIndex, Seeds, InOutResult); + + for (int32 SeedIndex : Seeds) + { + Enqueue(SeedIndex, LevelIndex, EAssetUsageProvenance::ExternalActor | EAssetUsageProvenance::SoftReference); + } + } + + int32 Head = 0; + while (Head < ScratchQueue.Num()) + { + const int32 Current = ScratchQueue[Head++]; + + for (const FAssetUsageEdge& Edge : Graph.GetDependencies(Current)) + { + int32 Target = Edge.TargetIndex; + if (!Graph.IsValidIndex(Target)) + { + continue; + } + + // A redirector reached on the way to a real asset must be followed through, or the + // redirector is reported used and the asset behind it is reported garbage. + EAssetUsageProvenance EdgeProvenance = EAssetUsageProvenance::None; + if (Graph.GetNode(Target).bIsRedirector) + { + const int32 Resolved = Graph.ResolveRedirector(Target); + if (Resolved != Target) + { + // Mark the redirector itself reachable too - it is a real package on disk that + // the level does depend on, and hiding it would make the report disagree with + // what a migrate or a copy actually pulls. + Enqueue(Target, Current, EAssetUsageProvenance::Redirector); + ++InOutResult.Stats.RedirectorsResolved; + Target = Resolved; + EdgeProvenance |= EAssetUsageProvenance::Redirector; + } + } + + if (Edge.IsHard()) + { + ++InOutResult.HardReferenceCounts[Target]; + EdgeProvenance |= EAssetUsageProvenance::HardReference; + } + else + { + ++InOutResult.SoftReferenceCounts[Target]; + EdgeProvenance |= EAssetUsageProvenance::SoftReference; + } + + if (Edge.IsEditorOnly()) + { + EdgeProvenance |= EAssetUsageProvenance::EditorOnly; + } + + const FAssetUsageNode& TargetNode = Graph.GetNode(Target); + if (TargetNode.bIsExternalPackage) + { + EdgeProvenance |= EAssetUsageProvenance::ExternalActor; + } + + // Reaching another World is NOT automatically a sublevel. An earlier version assumed it + // was, and on WP_Main that assumption pulled the whole of L_MainLevel in through + // BP_FirstPersonGameMode -> PDA_MenuSystemConfig: 9994 of 18136 rows belonged to a + // different map. A crossing only counts when the reference comes from the level package + // itself or from one of its external actor packages, which is the shape a streaming + // sublevel or a Level Instance actually has. + bool bStopAtForeignLevel = false; + + if (TargetNode.bIsLevel && Target != LevelIndex) + { + const FAssetUsageNode& SourceNode = Graph.GetNode(Current); + const bool bCrossingIsStructural = SourceNode.bIsLevel || SourceNode.bIsExternalPackage; + + if (bCrossingIsStructural) + { + EdgeProvenance |= EAssetUsageProvenance::Sublevel; + } + else if (!Options.bTraverseIntoOtherLevels) + { + // Record the foreign map as referenced - it genuinely is - but attribute its + // contents to itself. Its own sweep reports them. + bStopAtForeignLevel = true; + } + } + + // Accumulate provenance even when already visited: an asset reached by both a hard and + // a soft path should report both, otherwise the columns depend on BFS visit order. + if (OutReachable[Target]) + { + InOutResult.Provenance[Target] |= EdgeProvenance; + continue; + } + + if (bStopAtForeignLevel) + { + // Mark it reachable without queueing it, so the BFS never expands its dependencies. + OutReachable[Target] = true; + InOutResult.ReachableFromAnyLevel[Target] = true; + InOutResult.Provenance[Target] |= EdgeProvenance; + + if (Options.bRecordRoutes) + { + ScratchPredecessor[Target] = Current; + InOutResult.Routes.FindOrAdd(Target, BuildRouteString(LevelIndex, Target, ScratchPredecessor, Options.MaxRouteHops)); + } + + ++InOutResult.Stats.ForeignLevelsNotExpanded; + continue; + } + + Enqueue(Target, Current, EdgeProvenance); + } + } + + if (Options.bRecordRoutes) + { + // Walk the queue, not the whole index space: the queue already holds exactly the nodes this + // level reached. Scanning all packages per level would be 80k x 1207 iterations for nothing. + for (int32 Index : ScratchQueue) + { + if (InOutResult.Routes.Contains(Index)) + { + continue; + } + + InOutResult.Routes.Add(Index, BuildRouteString(LevelIndex, Index, ScratchPredecessor, Options.MaxRouteHops)); + } + } +} + +void FLevelUsageResolver::GatherExternalPackageSeeds(int32 LevelIndex, TArray& OutSeeds, FLevelUsageResult& InOutResult) const +{ + OutSeeds.Reset(); + + const FString LevelPackageName = Graph.GetNode(LevelIndex).PackageName.ToString(); + + // The plural form runs registered path-provider delegates, so plugins that mount their own + // external-actor roots are covered. Never hardcode "__ExternalActors__" here. + TArray ExternalPaths = ULevel::GetExternalActorsPaths(LevelPackageName); + ExternalPaths.Append(ULevel::GetExternalObjectsPaths(LevelPackageName)); + + if (ExternalPaths.Num() == 0) + { + return; + } + + FARFilter Filter; + Filter.bRecursivePaths = true; + Filter.bIncludeOnlyOnDiskAssets = true; + Filter.PackagePaths.Reserve(ExternalPaths.Num()); + + for (const FString& Path : ExternalPaths) + { + Filter.PackagePaths.Add(FName(*Path)); + } + + AssetRegistry.EnumerateAssets(Filter, [this, &OutSeeds, &InOutResult](const FAssetData& AssetData) + { + const int32 Index = Graph.FindPackageIndex(AssetData.PackageName); + if (Index != INDEX_NONE) + { + OutSeeds.AddUnique(Index); + ++InOutResult.Stats.ExternalPackagesSeeded; + } + return true; + }); +} + +FString FLevelUsageResolver::BuildRouteString(int32 LevelIndex, int32 AssetIndex, const TArray& Predecessor, int32 MaxHops) const +{ + TArray Chain; + + int32 Current = AssetIndex; + while (Current != INDEX_NONE && Chain.Num() < 1024) + { + Chain.Add(Current); + if (Current == LevelIndex) + { + break; + } + Current = Predecessor.IsValidIndex(Current) ? Predecessor[Current] : INDEX_NONE; + } + + Algo::Reverse(Chain); + + const auto NameAt = [this](int32 Index) + { + return Graph.GetNode(Index).AssetName.IsNone() + ? Graph.GetNode(Index).PackageName.ToString() + : Graph.GetNode(Index).AssetName.ToString(); + }; + + if (MaxHops > 0 && Chain.Num() > MaxHops) + { + // Keep both ends: the level explains scope, the tail explains the asset. The middle is + // where an artist stops reading anyway. + const int32 HeadCount = MaxHops / 2; + const int32 TailCount = MaxHops - HeadCount; + + TStringBuilder<512> Builder; + for (int32 i = 0; i < HeadCount; ++i) + { + if (i > 0) + { + Builder << TEXT(" -> "); + } + Builder << NameAt(Chain[i]); + } + + Builder << TEXT(" -> ... (") << (Chain.Num() - MaxHops) << TEXT(" more) -> "); + + for (int32 i = Chain.Num() - TailCount; i < Chain.Num(); ++i) + { + if (i > Chain.Num() - TailCount) + { + Builder << TEXT(" -> "); + } + Builder << NameAt(Chain[i]); + } + + return Builder.ToString(); + } + + TStringBuilder<512> Builder; + for (int32 i = 0; i < Chain.Num(); ++i) + { + if (i > 0) + { + Builder << TEXT(" -> "); + } + Builder << NameAt(Chain[i]); + } + + return Builder.ToString(); +} diff --git a/Source/AssetUsageAuditCore/Public/AssetDependencyClosure.h b/Source/AssetUsageAuditCore/Public/AssetDependencyClosure.h new file mode 100644 index 0000000..70b83db --- /dev/null +++ b/Source/AssetUsageAuditCore/Public/AssetDependencyClosure.h @@ -0,0 +1,128 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" + +class IAssetRegistry; + +/** + * Everything a set of packages needs in order to be usable somewhere else. + * + * Exists because "export the ticked meshes" without this produces meshes that open pink: the + * package file of a StaticMesh holds no materials and no textures, only references to them. The + * same applies to a Niagara system and its sprites, and to a Blueprint and everything it spawns. + * + * Deliberately a separate step rather than something FAssetUsageExporter does internally. The + * exporter stays a dumb file copier that needs no Asset Registry and is testable without one, and + * the caller keeps the chance to show "40 ticked, 380 will be written" before anything is copied - + * which matters, because that ratio surprises people. + * + * This is NOT IAssetTools::MigratePackages. Migrate walks the same closure but also decides where + * files go, prompts, and cannot be told to stop at a folder boundary. Here the traversal is bounded + * by the same exclusion list the audit uses, and engine content is left out by default because the + * destination project already has it. + */ +namespace AssetDependencyClosure +{ + struct FOptions + { + /** + * Package prefixes not to descend into, in either /Game/... or Content/... form. + * Normally the audit's own exclusion list, so an export cannot pull in content the report + * deliberately ignores. + */ + TArray ExcludePackagePaths; + + /** + * Include /Engine and /Temp packages in the result. + * + * Off by default. A mesh using DefaultMaterial genuinely depends on /Engine content, but + * copying it into the export folder is almost never what someone wants - the destination + * project ships the same file, and overwriting it there is worse than useless. + */ + bool bIncludeEnginePackages = false; + + /** + * Stop after this many hops from a seed. Zero means no limit. + * + * A limit is a blunt instrument and changes the answer rather than just shortening it, so it + * is off by default. It exists for the "just the materials, not the whole graph" case. + */ + int32 MaxDepth = 0; + + /** Called as (Visited, Queued). Return false to stop; the partial result is still returned. */ + TFunction OnProgress; + }; + + // Exported: Summarise is defined out of line, so the Editor module cannot link without this. + struct ASSETUSAGEAUDITCORE_API FStats + { + /** Seeds that were valid package names to begin with. */ + int32 SeedCount = 0; + + /** Size of the returned array, seeds included. */ + int32 TotalCount = 0; + + /** Reached but dropped, per reason. Kept apart so a surprising result can be explained. */ + int32 SkippedScript = 0; + int32 SkippedEngine = 0; + int32 SkippedExcluded = 0; + + /** + * Maps reached through an ordinary asset rather than through a level, and therefore not + * followed - nor included. + * + * The same rule FLevelUsageResolver applies, and for the same measured reason: on WP_Main + * the chain BP_FirstPersonGameMode -> PDA_MenuSystemConfig -> L_MainLevel drags in 9994 + * packages belonging to a different map. Without this the export dialog would quote an + * honest number for a wrong set. + * + * Excluded rather than merely not expanded, unlike in the audit. The exporter expands any + * .umap it is handed into its One File Per Actor packages, so including the map file would + * pull the foreign level's contents back in through the exporter instead of the closure. + */ + int32 ForeignLevelsSkipped = 0; + + /** Deepest hop count actually reached. Tells you whether MaxDepth did anything. */ + int32 DeepestHop = 0; + + bool bStoppedEarly = false; + + FString Summarise() const; + }; + + /** + * Seeds plus everything they depend on, transitively. + * + * Seeds come first and in their original order, so a caller can still tell what was asked for. + * The traversal uses AssetUsageAudit::MakeTraversalQuery() - package category, no requirements - + * for the same reason the audit does: a Hard-only query silently drops every soft edge, which + * includes every One File Per Actor package and every TSoftObjectPtr a Blueprint resolves. + */ + /** One ticked asset and everything it pulls in, the seed first. */ + struct FSeedClosure + { + FName Seed; + TArray Packages; + }; + + /** + * Closure per seed, rather than one closure over all of them. + * + * Needed by the folder-per-asset layout: a texture shared by forty meshes has to appear in all + * forty folders, and the flat Gather deliberately returns it once. Costs one traversal per + * seed, so it is the slower call by construction - use Gather when a single list will do. + */ + ASSETUSAGEAUDITCORE_API TArray GatherPerSeed( + IAssetRegistry& Registry, + const TArray& Seeds, + const FOptions& Options, + FStats& OutStats); + + ASSETUSAGEAUDITCORE_API TArray Gather( + IAssetRegistry& Registry, + const TArray& Seeds, + const FOptions& Options, + FStats& OutStats); +} diff --git a/Source/AssetUsageAuditCore/Public/AssetExportLayout.h b/Source/AssetUsageAuditCore/Public/AssetExportLayout.h new file mode 100644 index 0000000..65eaf4d --- /dev/null +++ b/Source/AssetUsageAuditCore/Public/AssetExportLayout.h @@ -0,0 +1,103 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" +#include "AssetDependencyClosure.h" +#include "AssetUsageAuditTypes.h" +#include "AssetUsageExporter.h" + +class IAssetRegistry; + +/** + * Decides where each exported file goes. + * + * Deliberately separate from FAssetUsageExporter. The exporter's job is to copy a package to a + * path and nothing else - it has no idea what a preset or a dependency is, and keeping it that way + * is why its tests can run against invented package names. Everything that needs the Asset + * Registry, the class hierarchy or the dependency closure lives here instead, and hands the + * exporter a finished list of "these packages, into this subfolder". + */ +namespace AssetExportLayout +{ + /** + * A set of packages destined for one subfolder of the export target. + * + * An alias, not a second struct: this namespace exists to produce exactly what the exporter + * consumes, and two identical types would mean a conversion loop whose only job is to prove + * they stayed identical. + */ + using FGroup = FAssetUsageExporter::FExportGroup; + + struct FOptions + { + EExportLayout Layout = EExportLayout::Flat; + + /** + * Sort each asset's dependencies into per-type subfolders: Texture/, Material/, and so on. + * + * Only meaningful with FolderPerAsset - there is no "each asset's dependencies" to sort in + * the other layouts. BuildGroups ignores it rather than inventing a meaning. + * + * The seed itself stays at the root of its own folder. It is the subject of that folder; + * filing it under StaticMesh/ next to its own dependencies would bury it. + */ + bool bGroupDependenciesByType = false; + }; + + struct ASSETUSAGEAUDITCORE_API FStats + { + int32 GroupCount = 0; + + /** Distinct packages across every group. */ + int32 DistinctPackages = 0; + + /** + * Total placements, counting a shared dependency once per folder it lands in. + * + * This, not DistinctPackages, is the number of files that will be written. Under + * FolderPerAsset the two differ sharply and the user has to be told which one they are + * looking at before the copy starts. + */ + int32 FilePlacements = 0; + + /** Folder names that collided and were given a numeric suffix. */ + int32 RenamedFolders = 0; + + FString Summarise() const; + }; + + /** + * Build the export groups. + * + * @param SeedClosures For FolderPerAsset, one entry per ticked asset from GatherPerSeed. For + * the other layouts only the union of Packages is used, so a single + * closure covering everything is enough. + */ + ASSETUSAGEAUDITCORE_API TArray BuildGroups( + IAssetRegistry& Registry, + const TArray& SeedClosures, + const FOptions& Options, + FStats& OutStats); + + /** + * Map every class the type presets cover, and their subclasses, to a folder name. + * + * Folder names are the preset names verbatim - Texture, Material, StaticMesh - rather than a + * second set of invented labels. The Types filter in the panel already shows those words, so + * the folders a user gets match the words they filtered by. + * + * Built once per export: expanding the class hierarchy costs a registry call per preset. + */ + ASSETUSAGEAUDITCORE_API TMap BuildTypeFolderMap(IAssetRegistry& Registry); + + /** + * Folder name for one package, using a map from BuildTypeFolderMap. + * Returns "Other" for anything no preset covers, never an empty string - an empty name would + * silently put the file in the parent folder and look like the grouping had failed. + */ + ASSETUSAGEAUDITCORE_API FString TypeFolderForPackage( + IAssetRegistry& Registry, + FName PackageName, + const TMap& FolderByClass); +} diff --git a/Source/AssetUsageAuditCore/Public/AssetExportManifest.h b/Source/AssetUsageAuditCore/Public/AssetExportManifest.h new file mode 100644 index 0000000..29ae160 --- /dev/null +++ b/Source/AssetUsageAuditCore/Public/AssetExportManifest.h @@ -0,0 +1,83 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" +#include "AssetUsageAuditTypes.h" +#include "AssetUsageExporter.h" + +class IAssetRegistry; + +/** + * The JSON written beside a reference-breaking export. + * + * Why it exists: references inside a .uasset are stored as full package names, so a copied file + * only resolves when it sits at exactly that package path in the destination. The Flat and + * FolderPerAsset layouts deliberately do not put it there - they arrange files for a person to read + * and hand around - so the reference graph is lost the moment the files leave the project. + * + * The manifest records that graph next to the files, so a later import can rebuild it rather than + * guess. It is written from what the exporter actually wrote, never from what it intended to write: + * the two differ exactly when the collision policy renamed something, which is the case a manifest + * has to get right. + * + * Migrate needs none of this - the engine preserves the paths itself - and asking for a manifest + * there is treated as a caller mistake rather than silently producing a misleading file. + */ +namespace AssetExportManifest +{ + /** Bumped when the schema changes in a way a reader must notice. */ + inline constexpr int32 SchemaVersion = 1; + + /** Manifest filename, written into the export root. */ + inline const TCHAR* FileName = TEXT("AssetUsageAudit.manifest.json"); + + struct FOptions + { + /** Export root. The manifest is written here and paths are relative to it. */ + FString TargetDirectory; + + /** Recorded so a reader knows why the paths look the way they do. */ + EExportLayout Layout = EExportLayout::Flat; + + EExportCollisionPolicy CollisionPolicy = EExportCollisionPolicy::Index; + + /** + * Packages the user ticked, as opposed to those pulled in as dependencies. + * + * Kept apart because an importer needs to know which assets were the point and which came + * along to make them work - "restore what I exported" and "restore everything in this + * folder" are different requests. + */ + TSet SeedPackages; + }; + + struct ASSETUSAGEAUDITCORE_API FResult + { + bool bSuccess = false; + + /** Absolute path of the manifest, empty on failure. */ + FString FilePath; + + int32 EntriesWritten = 0; + + /** Dependency edges recorded across all entries. */ + int32 EdgesRecorded = 0; + + FString ErrorMessage; + }; + + /** + * Write the manifest. + * + * @param WrittenFiles From FAssetUsageExporter::FResult::WrittenFiles, which requires the + * exporter to have been run with bRecordWrittenFiles. An empty array is a + * failure rather than an empty manifest: it almost always means the flag + * was forgotten, and an empty manifest beside a full folder is worse than + * no manifest at all. + */ + ASSETUSAGEAUDITCORE_API FResult Write( + IAssetRegistry& Registry, + const TArray& WrittenFiles, + const FOptions& Options); +} diff --git a/Source/AssetUsageAuditCore/Public/AssetExportNaming.h b/Source/AssetUsageAuditCore/Public/AssetExportNaming.h new file mode 100644 index 0000000..5ad24dd --- /dev/null +++ b/Source/AssetUsageAuditCore/Public/AssetExportNaming.h @@ -0,0 +1,57 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" +#include "AssetUsageAuditTypes.h" + +/** + * Collision-handling for export filenames. + * + * Deliberately a free function over strings with an injectable existence predicate: the rule + * ("increment an existing index, do not append a second one") is fiddly on real asset names like + * SM_Rock_02_v3, and it must be unit-testable without touching a filesystem or an editor. + */ +namespace AssetExportNaming +{ + /** Predicate answering "does this filename already exist in the target folder". */ + using FExistsPredicate = TFunctionRef; + + /** + * Split a base name into stem and trailing numeric index. + * + * "Foo" -> {"Foo", INDEX_NONE, 0} + * "Foo_1" -> {"Foo", 1, 1} + * "Foo_007" -> {"Foo", 7, 3} <- padding width preserved + * "SM_Rock_02_v3"-> {"SM_Rock_02_v3", INDEX_NONE, 0} <- v3 is not a numeric suffix + * "SM_Rock_02" -> {"SM_Rock", 2, 2} + * "Foo_" -> {"Foo_", INDEX_NONE, 0} <- empty suffix is not an index + * + * @param BaseName Filename without extension. + * @param OutStem Portion before the trailing _N, or the whole name when there is none. + * @param OutIndex Parsed index, or INDEX_NONE. + * @param OutPadWidth Digit count of the parsed index, so Foo_007 -> Foo_008 not Foo_8. + */ + ASSETUSAGEAUDITCORE_API void SplitTrailingIndex(const FString& BaseName, FString& OutStem, int32& OutIndex, int32& OutPadWidth); + + /** + * Compose a filename from stem, index and padding. + * (Foo, INDEX_NONE, 0) -> "Foo" + * (Foo, 1, 1) -> "Foo_1" + * (Foo, 8, 3) -> "Foo_008" + */ + ASSETUSAGEAUDITCORE_API FString ComposeIndexedName(const FString& Stem, int32 Index, int32 PadWidth); + + /** + * Resolve a target filename under the given collision policy. + * + * Overwrite -> returns DesiredFileName unchanged. + * Index -> returns the first non-colliding name, incrementing any existing trailing index. + * + * @param DesiredFileName Filename with extension, e.g. "SM_Rock.uasset". + * @param Policy Overwrite or Index. + * @param Exists Predicate over filenames (with extension) in the target folder. + * @return Filename with extension that does not collide, or DesiredFileName under Overwrite. + */ + ASSETUSAGEAUDITCORE_API FString ResolveCollision(const FString& DesiredFileName, EExportCollisionPolicy Policy, FExistsPredicate Exists); +} diff --git a/Source/AssetUsageAuditCore/Public/AssetTypeFilter.h b/Source/AssetUsageAuditCore/Public/AssetTypeFilter.h new file mode 100644 index 0000000..79e15a7 --- /dev/null +++ b/Source/AssetUsageAuditCore/Public/AssetTypeFilter.h @@ -0,0 +1,85 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" + +class IAssetRegistry; +struct FAssetUsageNode; + +/** + * Named groups of asset classes, so an artist picks "VFX" rather than typing a class path. + */ +enum class EAssetTypePreset : uint8 +{ + StaticMesh, + SkeletalMesh, + Material, + Texture, + VFX, + Sound, + Blueprint, + Level, + DataAsset, + Animation +}; + +ASSETUSAGEAUDITCORE_API const TCHAR* LexToString(EAssetTypePreset Preset); + +/** Every preset, in the order the UI should list them. */ +ASSETUSAGEAUDITCORE_API TArray GetAllAssetTypePresets(); + +/** Class paths a preset stands for, before subclass expansion. */ +ASSETUSAGEAUDITCORE_API TArray GetPresetClassPaths(EAssetTypePreset Preset); + +/** + * Matches assets by class, with the two things that make class filtering work in this project. + * + * 1. Subclass expansion via GetDerivedClassNames, so "Material" also matches MaterialInstanceConstant. + * + * 2. Blueprint awareness. A BP asset's own class is always /Script/Engine.Blueprint, so filtering on + * class alone finds no Blueprints at all - fatal in a project whose gameplay is entirely Blueprints. + * The gameplay class lives in the GeneratedClass registry tag, captured per node during the sweep, + * and a node matches when EITHER its class or its generated class is in the expanded set. + */ +class ASSETUSAGEAUDITCORE_API FAssetTypeFilter +{ +public: + /** Empty filter matches everything. */ + FAssetTypeFilter() = default; + + void AddPreset(EAssetTypePreset Preset); + + /** Raw class path, e.g. "/Script/Engine.StaticMesh" or a short name like "StaticMesh". */ + void AddRawClass(const FString& ClassPathOrName); + + /** + * Expand every added class to include its subclasses. + * Must be called after the Add* calls and before Matches. + */ + void Compile(IAssetRegistry& AssetRegistry); + + bool IsEmpty() const + { + return RequestedClasses.IsEmpty(); + } + + bool Matches(const FAssetUsageNode& Node) const; + + /** Human-readable description for the report header. */ + FString Describe() const; + + /** Raw class strings that could not be resolved to a real class. */ + TConstArrayView GetUnresolvedClasses() const + { + return UnresolvedClasses; + } + +private: + TArray RequestedClasses; + TArray RequestedDescriptions; + TArray UnresolvedClasses; + + TSet ExpandedClasses; + bool bCompiled = false; +}; diff --git a/Source/AssetUsageAuditCore/Public/AssetUsageAuditCoreModule.h b/Source/AssetUsageAuditCore/Public/AssetUsageAuditCoreModule.h new file mode 100644 index 0000000..578f268 --- /dev/null +++ b/Source/AssetUsageAuditCore/Public/AssetUsageAuditCoreModule.h @@ -0,0 +1,18 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Modules/ModuleManager.h" + +ASSETUSAGEAUDITCORE_API DECLARE_LOG_CATEGORY_EXTERN(LogAssetUsageAudit, Log, All); + +/** + * Analysis-only module. Deliberately links no UI and no editor framework: + * every dependency here is available in a commandlet and in a standalone Program, + * which is what keeps a future headless mode a packaging question rather than a rewrite. + */ +class FAssetUsageAuditCoreModule : public IModuleInterface +{ +public: + virtual void StartupModule() override; + virtual void ShutdownModule() override; +}; diff --git a/Source/AssetUsageAuditCore/Public/AssetUsageAuditTypes.h b/Source/AssetUsageAuditCore/Public/AssetUsageAuditTypes.h new file mode 100644 index 0000000..20f9de6 --- /dev/null +++ b/Source/AssetUsageAuditCore/Public/AssetUsageAuditTypes.h @@ -0,0 +1,235 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" +#include "Misc/AssetRegistryInterface.h" + +// EExportLayout is a UENUM so the editor settings can expose it as a dropdown. It is the only +// reflected type in this module; everything else here stays plain C++ so the analysis has no +// reflection cost. Must be the last include, as UHT requires. +#include "AssetUsageAuditTypes.generated.h" + +/** + * Why an asset is considered used - or why we cannot tell. + * + * Deliberately five states, never a bool. The Asset Registry cannot see string-built paths, + * OpenLevel(FName), DataTable row contents or FMOD event references; reporting those as + * "unused" is how an audit tool causes a deletion incident. Unknown is a legitimate answer. + */ +enum class EAssetUsageVerdict : uint8 +{ + /** Reachable from at least one level package. Columns say through which, and hard or soft. */ + UsedOnLevel, + + /** Has referencers, but no chain from any level reaches it. */ + UsedByAssetsOnly, + + /** Found by scanning Config/ and Source/ for /Game paths. Provenance carries file and line. */ + ReferencedFromConfigOrSource, + + /** GetReferencers returned nothing and no level reaches it. */ + Unreferenced, + + /** Falls into a known registry blind spot. Never present this as "safe to delete". */ + Unknown +}; + +ASSETUSAGEAUDITCORE_API const TCHAR* LexToString(EAssetUsageVerdict Verdict); + +/** + * Every verdict, in the order the UI should list them. + * + * Single source of truth: the menu, the report and the settings restore all need this list, and + * three hand-written copies would drift the moment a sixth verdict is added. + */ +ASSETUSAGEAUDITCORE_API TArray GetAllAssetUsageVerdicts(); + +/** How an asset reference was discovered. Bitmask - an asset can be reached several ways. */ +enum class EAssetUsageProvenance : uint8 +{ + None = 0, + + /** Direct hard dependency edge (EDependencyProperty::Hard present). */ + HardReference = 1 << 0, + + /** Soft dependency edge - the lack of Hard. Includes every external actor edge. */ + SoftReference = 1 << 1, + + /** Reached through an OFPA __ExternalActors__ / __ExternalObjects__ package. */ + ExternalActor = 1 << 2, + + /** Reached through ULevelStreaming::WorldAsset or ALevelInstance::WorldAsset. */ + Sublevel = 1 << 3, + + /** Path literal found in Config/*.ini. */ + ConfigFile = 1 << 4, + + /** Path literal found in Source/**.cpp|h. */ + SourceFile = 1 << 5, + + /** Edge is editor-only (EDependencyProperty::Game absent) - not shipped, but still a use. */ + EditorOnly = 1 << 6, + + /** Reached through a redirector that we resolved. */ + Redirector = 1 << 7 +}; + +ENUM_CLASS_FLAGS(EAssetUsageProvenance); + +ASSETUSAGEAUDITCORE_API FString ProvenanceToString(EAssetUsageProvenance Provenance); + +/** + * How exported files are arranged under the target folder. + * + * Deliberately an enum rather than a set of booleans. These are mutually exclusive: "mirror the + * /Game tree" and "one folder per ticked asset" cannot both be true, and a pair of booleans would + * make that contradiction expressible and then silently resolve it one way. + */ +UENUM() +enum class EExportLayout : uint8 +{ + /** Everything straight into the target folder. This is why the collision policy exists. */ + Flat UMETA(DisplayName = "Flat - everything in one folder"), + + /** Mirror the package path, so /Game/Art/SM_Rock lands in Game/Art/SM_Rock.uasset. */ + MirrorTree UMETA(DisplayName = "Mirror the /Game folder tree"), + + /** + * A subfolder per ticked asset, with that asset's dependencies beside it. + * + * Note the consequence: a texture shared by forty meshes is copied forty times, once into each + * mesh's folder. That is the point - each folder is self-contained and can be handed over on + * its own - but it means the file count exceeds the number of distinct assets, sometimes by a + * lot. The export dialog states the real figure before anything is written. + */ + FolderPerAsset, + + /** + * Hand the packages to IAssetTools::MigratePackages, aimed at another project's Content folder. + * + * The only layout whose output opens in Unreal with its references intact. References inside a + * .uasset are stored as full package names, so a file only resolves when it sits at exactly the + * same package path in the destination; Migrate is the engine's own code for arranging that, + * including the OFPA actor packages of a level. + * + * Not implemented in this module. AssetTools is editor-only, and the analysis here has to stay + * runnable from a commandlet - the editor module dispatches this value to its own path. Anything + * in Core that switches on the layout must therefore treat Migrate as "not mine". + */ + Migrate +}; + +ASSETUSAGEAUDITCORE_API const TCHAR* LexToString(EExportLayout Layout); + +/** + * What to do when an exported file already exists in the target folder. + */ +enum class EExportCollisionPolicy : uint8 +{ + /** Replace the existing file. */ + Overwrite, + + /** Append or increment a numeric suffix: Foo -> Foo_1, Foo_7 -> Foo_8. */ + Index +}; + +/** + * One row of the audit result. + */ +struct ASSETUSAGEAUDITCORE_API FAssetUsageRow +{ + /** Package name, e.g. /Game/Space/Art/SM_Rock. */ + FName PackageName; + + /** Asset name without path. */ + FName AssetName; + + /** Class path of the asset, e.g. /Script/Engine.StaticMesh. */ + FTopLevelAssetPath ClassPath; + + /** + * For Blueprints, the generated gameplay class from the GeneratedClass tag. + * A BP asset's ClassPath is always /Script/Engine.Blueprint, which is useless for filtering. + */ + FTopLevelAssetPath GeneratedClassPath; + + /** Path relative to the project root, e.g. Content/Space/Art/SM_Rock.uasset. */ + FString PathFromProjectRoot; + + EAssetUsageVerdict Verdict = EAssetUsageVerdict::Unknown; + + EAssetUsageProvenance Provenance = EAssetUsageProvenance::None; + + /** Levels this asset is reachable from. Includes both the sublevel and its parent map. */ + TArray Levels; + + /** How many incoming edges carried EDependencyProperty::Hard. */ + int32 HardReferenceCount = 0; + + /** How many incoming edges lacked Hard. */ + int32 SoftReferenceCount = 0; + + /** + * Human-readable chain explaining the verdict, e.g. + * "WP_Main -> __ExternalActors__/.../A2B -> BP_Rock -> SM_Rock". + * A verdict without a route is unactionable for an artist. + */ + FString Route; + + /** For ReferencedFromConfigOrSource - which file and line named this asset. */ + FString ProvenanceDetail; + + bool IsUsedOnAnyLevel() const + { + return Verdict == EAssetUsageVerdict::UsedOnLevel; + } +}; + +/** + * Metadata written into the report header so a stale report can never be mistaken for a fresh one. + */ +struct ASSETUSAGEAUDITCORE_API FAssetUsageReportHeader +{ + FDateTime GeneratedAt; + FString EngineVersion; + FString ToolVersion; + FString ProjectName; + + /** Human-readable description of the filters that produced this result. */ + TArray AppliedFilters; + + int32 LevelsScanned = 0; + int32 AssetsScanned = 0; + double ScanDurationSeconds = 0.0; +}; + +/** + * The dependency query this tool must always use. + * + * Package category, NoRequirements flags. NOT Hard. + * + * Two engine facts make this non-negotiable: + * + * 1. FExternalObjectAndActorDependencyGatherer (ExternalObjectAndActorDependencyGatherer.cpp:22) + * emits map -> external actor edges with property mask Game|Build. Hard is absent, and + * AssetRegistryInterface.h:95 states the lack of Hard *is* a soft dependency. Querying with + * Hard therefore drops every external actor - 16126 packages in this project. + * + * 2. In EDependencyQuery, Soft is literally defined as NotHard. So Hard|Soft means + * "require Hard AND require not-Hard" and matches nothing at all. + * + * Hard vs soft is a column in the report, never a filter on the query. + */ +namespace AssetUsageAudit +{ + inline UE::AssetRegistry::FDependencyQuery MakeTraversalQuery() + { + return UE::AssetRegistry::FDependencyQuery(); + } + + inline UE::AssetRegistry::EDependencyCategory MakeTraversalCategory() + { + return UE::AssetRegistry::EDependencyCategory::Package; + } +} diff --git a/Source/AssetUsageAuditCore/Public/AssetUsageAuditor.h b/Source/AssetUsageAuditCore/Public/AssetUsageAuditor.h new file mode 100644 index 0000000..bac12be --- /dev/null +++ b/Source/AssetUsageAuditCore/Public/AssetUsageAuditor.h @@ -0,0 +1,85 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" +#include "AssetUsageAuditTypes.h" +#include "AssetTypeFilter.h" +#include "AssetUsageGraph.h" +#include "LevelUsageResolver.h" + +class IAssetRegistry; + +struct FAssetUsageAuditRequest +{ + /** Roots to sweep. Empty means /Game. */ + TArray IncludePackagePaths; + + /** Package or content-relative prefixes to skip, e.g. "Content/3rdParty". */ + TArray ExcludePackagePaths; + + /** Levels to consider. Empty means every level found. */ + TArray LevelPackages; + + /** Type filter applied to the reported rows, not to the traversal. */ + FAssetTypeFilter TypeFilter; + + /** Scan Config/ and Source/ for path literals the registry cannot see. */ + bool bScanIndirectReferences = true; + + /** Drop rows for __ExternalActors__ / __ExternalObjects__ packages. */ + bool bHideExternalPackages = true; + + /** Drop redirector rows; they are plumbing, not content an artist acts on. */ + bool bHideRedirectors = true; + + /** Report only assets that no level reaches. */ + bool bOnlyUnusedAssets = false; + + FLevelUsageResolveOptions ResolveOptions; +}; + +// Exported: CountByVerdict is defined out of line, so the Editor module cannot link without this. +struct ASSETUSAGEAUDITCORE_API FAssetUsageAuditResult +{ + TArray Rows; + FAssetUsageReportHeader Header; + FAssetUsageGraphStats GraphStats; + FLevelUsageStats LevelStats; + + int32 CountByVerdict(EAssetUsageVerdict Verdict) const; +}; + +/** + * Runs the whole analysis: graph, level reachability, indirect scan, verdicts. + * + * Free of UI and of the editor asset pipeline, so the Slate panel and a future commandlet can + * call exactly the same code and cannot drift apart. + */ +class ASSETUSAGEAUDITCORE_API FAssetUsageAuditor +{ +public: + static FAssetUsageAuditResult Run(IAssetRegistry& AssetRegistry, FAssetUsageAuditRequest& Request); + + /** + * Classify one asset. + * + * Pure and exposed for testing: the ordering of these rules is the difference between a tool + * people trust and one that tells an artist to delete the GameMode. + * + * @param bReachableFromLevel A level's traversal reached this asset. + * @param bHasReferencers Something in the graph depends on it. + * @param bFoundInConfigOrSource A path literal in Config/ or Source/ named it. + * @param bIsBlindSpot Its type is one the registry cannot track reliably, e.g. FMOD. + */ + static EAssetUsageVerdict ClassifyVerdict(bool bReachableFromLevel, bool bHasReferencers, bool bFoundInConfigOrSource, bool bIsBlindSpot); + + /** + * True for asset types whose real usage the Asset Registry cannot see. + * + * FMOD is the concrete case on this project: it resolves events by string path through the + * FMOD Studio runtime, entirely outside the UObject reference graph, so "no referencers" + * carries no information at all for an FMOD asset. + */ + static bool IsRegistryBlindSpot(const FAssetUsageNode& Node); +}; diff --git a/Source/AssetUsageAuditCore/Public/AssetUsageExporter.h b/Source/AssetUsageAuditCore/Public/AssetUsageExporter.h new file mode 100644 index 0000000..4b4dd1e --- /dev/null +++ b/Source/AssetUsageAuditCore/Public/AssetUsageExporter.h @@ -0,0 +1,219 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" +#include "AssetUsageAuditTypes.h" + +/** + * Copies package files out of the project. + * + * Deliberately a plain file copy rather than IAssetTools::MigratePackages. Migrate pulls the whole + * dependency closure, which is emphatically not what someone asked for when they ticked eleven + * meshes, and it lives in an editor-only module. A byte copy loads no UObject, needs no editor, + * and gives exactly the files that were ticked. + */ +class ASSETUSAGEAUDITCORE_API FAssetUsageExporter +{ +public: + struct FOptions + { + /** Absolute destination directory. Created if missing. */ + FString TargetDirectory; + + /** What to do when a file of that name is already there. */ + EExportCollisionPolicy CollisionPolicy = EExportCollisionPolicy::Index; + + /** + * Put every file directly in the target folder rather than mirroring the /Game tree. + * + * Flat is what people expect from "export to a folder" and is why the collision policy + * exists at all - mirroring the tree makes collisions nearly impossible but hands back a + * deep folder structure nobody asked for. + */ + bool bFlatten = true; + + /** + * Also copy the __ExternalActors__ / __ExternalObjects__ packages belonging to any exported + * level. Without these an exported OFPA map opens empty in the destination project. + */ + bool bIncludeExternalPackages = true; + + /** + * Record where each package actually ended up, in FResult::WrittenFiles. + * + * Off by default because it costs a string per file. Required by the dependency manifest: + * a manifest built from intended paths rather than written ones is wrong precisely when the + * collision policy renamed something, which is when someone needs it most. + */ + bool bRecordWrittenFiles = false; + + /** Called as (Done, Total). Return false to cancel; already-copied files are kept. */ + TFunction OnProgress; + }; + + /** One copied file, as it was actually written. */ + struct FWrittenFile + { + FName PackageName; + + /** Path relative to FOptions::TargetDirectory, with the final on-disk filename. */ + FString RelativePath; + }; + + // Exported: Summarise is defined out of line, so the Editor module cannot link without this. + struct ASSETUSAGEAUDITCORE_API FResult + { + /** Filled only when FOptions::bRecordWrittenFiles was set. */ + TArray WrittenFiles; + + bool bSuccess = false; + int32 FilesCopied = 0; + int32 FilesRenamed = 0; + int32 FilesOverwritten = 0; + int32 FilesMissingOnDisk = 0; + int32 ExternalPackagesCopied = 0; + bool bCancelled = false; + + /** One line per failure, safe to show in a dialog. */ + TArray Errors; + + FString Summarise() const; + }; + + /** + * A set of packages destined for one subfolder of the target. + * + * The exporter does not decide what the subfolder means - AssetExportLayout does, and hands + * the result over already named. Keeping the split means this class still knows nothing about + * presets, dependencies or the Asset Registry, which is why its specs can run on package names + * that do not exist. + */ + struct FExportGroup + { + /** Relative to FOptions::TargetDirectory. Empty writes straight into it. */ + FString RelativeDir; + + TArray Packages; + }; + + /** + * Copy packages, each group into its own subfolder. + * + * Collision handling is per destination folder, not global: two groups may each hold a file + * called SM_Rock.uasset without either being renamed, because they land in different folders. + * That is the point of the folder-per-asset layout. + */ + static FResult ExportPackageGroups(const TArray& Groups, const FOptions& Options); + + /** Single-group convenience: everything straight into the target. */ + static FResult ExportPackageFiles(const TArray& PackageNames, const FOptions& Options); + + // --- Exchange formats ------------------------------------------------------------------------ + + /** + * One asset to convert. Deliberately not FAssetUsageRow: conversion needs three fields and + * taking the whole row would make this callable only from a finished audit. + */ + struct FExportItem + { + FName PackageName; + FName AssetName; + + /** Asset class, e.g. /Script/Engine.StaticMesh. Chooses the output format. */ + FTopLevelAssetPath ClassPath; + }; + + struct FExchangeOptions + { + FString TargetDirectory; + + EExportCollisionPolicy CollisionPolicy = EExportCollisionPolicy::Index; + + bool bFlatten = true; + + /** + * Asset class short name -> file extension without the dot, e.g. {"StaticMesh", "fbx"}. + * + * Passed in rather than hardcoded so the Core module stays free of the settings object, and + * so a class with no sensible exchange format is a configuration fact rather than a silent + * omission. Lookup walks up the class hierarchy, so mapping MaterialInterface also covers + * MaterialInstanceConstant. + */ + TMap FormatByClass; + + /** + * Run garbage collection every N assets. + * + * Unlike a file copy, this path loads every UObject it touches. Exporting a few thousand + * meshes without collecting will exhaust memory long before the export finishes. Zero + * disables it. + */ + int32 CollectGarbageEvery = 64; + + /** Called as (Done, Total). Return false to cancel; files already written are kept. */ + TFunction OnProgress; + }; + + struct ASSETUSAGEAUDITCORE_API FExchangeResult + { + bool bSuccess = false; + int32 FilesWritten = 0; + int32 FilesRenamed = 0; + + /** No extension configured for the asset's class. Not an error - a deliberate omission. */ + int32 SkippedNoFormat = 0; + + /** The asset would not load. Counted separately from an exporter refusing to run. */ + int32 SkippedNotLoaded = 0; + + /** Loaded fine, but no UExporter is registered for that class and extension. */ + int32 SkippedNoExporter = 0; + + bool bCancelled = false; + + TArray Errors; + + FString Summarise() const; + }; + + /** + * Convert assets to interchange formats (FBX, PNG, WAV, ...) via UAssetExportTask. + * + * Loads every asset, so it is orders of magnitude slower than ExportPackageFiles and must be + * driven with a progress callback. bPrompt is forced false and bAutomated true: a modal file + * dialog per asset would hang an unattended run, and this is the whole reason the engine has + * those flags. + */ + static FExchangeResult ExportConvertedAssets(const TArray& Items, const FExchangeOptions& Options); + + /** + * Extension for an asset class, following the class hierarchy upward. + * Returns an empty string when nothing in the chain is mapped. + */ + static FString FindFormatForClass(const FTopLevelAssetPath& ClassPath, const TMap& FormatByClass); + + /** The mapping the tool ships with. Editor settings seed themselves from this. */ + static TMap GetDefaultFormatByClass(); + + /** + * Resolve a package name to its file on disk, trying both asset and map extensions. + * Returns an empty string when the package has no file (script packages, unsaved assets). + */ + static FString ResolvePackageFilePath(FName PackageName); + +private: + /** + * The copy loop, shared by every entry point. + * + * Takes the destination folder per entry rather than deriving it, so that deciding where a file + * goes and actually writing it stay separate concerns. Result is passed by reference because + * the caller has already recorded setup failures into it. + */ + static FResult CopyExpandedPackages( + const TArray& Expanded, + const TArray& DestinationDirs, + const TBitArray<>& IsExternalExpansion, + const FOptions& Options, + FResult& Result); +}; diff --git a/Source/AssetUsageAuditCore/Public/AssetUsageGraph.h b/Source/AssetUsageAuditCore/Public/AssetUsageGraph.h new file mode 100644 index 0000000..b795012 --- /dev/null +++ b/Source/AssetUsageAuditCore/Public/AssetUsageGraph.h @@ -0,0 +1,188 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" +#include "AssetUsageAuditTypes.h" +#include "AssetRegistry/AssetData.h" + +class IAssetRegistry; + +/** + * One dependency edge, stored by dense index rather than by FName. + * + * 8 bytes. At this project's scale (80k packages, several hundred thousand edges) the difference + * between this and a TMap> is the difference between a tool that answers in + * seconds and one nobody waits for. + */ +struct FAssetUsageEdge +{ + /** Dense index of the target package in FAssetUsageGraph. */ + int32 TargetIndex = INDEX_NONE; + + /** Edge properties as reported by the registry: Hard / Game / Build. */ + UE::AssetRegistry::EDependencyProperty Properties = UE::AssetRegistry::EDependencyProperty::None; + + bool IsHard() const + { + return EnumHasAnyFlags(Properties, UE::AssetRegistry::EDependencyProperty::Hard); + } + + /** Lack of Hard is what the engine calls a soft dependency (AssetRegistryInterface.h:95). */ + bool IsSoft() const + { + return !IsHard(); + } + + /** Lack of Game means the edge is editor-only and will not survive a cook. */ + bool IsEditorOnly() const + { + return !EnumHasAnyFlags(Properties, UE::AssetRegistry::EDependencyProperty::Game); + } +}; + +/** + * Per-package facts captured once during the sweep, so later passes never re-query the registry. + */ +struct FAssetUsageNode +{ + FName PackageName; + FName AssetName; + FTopLevelAssetPath ClassPath; + + /** + * Gameplay class behind a Blueprint, read from the GeneratedClass asset-registry tag. + * + * Needed because a BP asset's own ClassPath is always /Script/Engine.Blueprint. Filtering + * ClassPaths alone finds zero Blueprints, which in this project means finding almost nothing. + */ + FTopLevelAssetPath GeneratedClassPath; + + /** ClassPath is /Script/Engine.World. */ + bool bIsLevel = false; + + /** Package lives under __ExternalActors__ or __ExternalObjects__. */ + bool bIsExternalPackage = false; + + /** Asset is an ObjectRedirector and must be followed through, not reported. */ + bool bIsRedirector = false; +}; + +struct FAssetUsageGraphBuildOptions +{ + /** Roots to sweep. Defaults to /Game. */ + TArray IncludePackagePaths; + + /** Package path prefixes to skip entirely, e.g. Content/3rdParty. */ + TArray ExcludePackagePaths; + + /** Collect dependency edges. Off gives a much faster inventory-only pass. */ + bool bGatherDependencies = true; +}; + +struct FAssetUsageGraphStats +{ + int32 NumPackages = 0; + int32 NumEdges = 0; + int32 NumLevels = 0; + int32 NumExternalPackages = 0; + double EnumerateSeconds = 0.0; + double DependencySeconds = 0.0; + + double TotalSeconds() const + { + return EnumerateSeconds + DependencySeconds; + } +}; + +/** + * Dense, immutable-after-build dependency graph over the project's packages. + * + * Edges are stored CSR-style: one flat FAssetUsageEdge array plus an offset table, so a node's + * dependency list is a contiguous view with no per-node allocation. + * + * The graph deliberately stores dependencies in BOTH directions. Forward edges answer "what does + * this level use"; reverse edges answer "is this asset referenced by anything at all", which is + * what separates the Unreferenced verdict from UsedByAssetsOnly. + */ +class ASSETUSAGEAUDITCORE_API FAssetUsageGraph +{ +public: + /** + * Sweep the registry and build the graph. + * + * The registry must already be populated. In a commandlet the AssetRegistry module gathers + * synchronously on load; in the editor callers must wait for OnFilesLoaded first. + */ + void Build(IAssetRegistry& AssetRegistry, const FAssetUsageGraphBuildOptions& Options); + + void Reset(); + + int32 Num() const + { + return Nodes.Num(); + } + + bool IsValidIndex(int32 Index) const + { + return Nodes.IsValidIndex(Index); + } + + /** INDEX_NONE when the package was not part of the sweep. */ + int32 FindPackageIndex(FName PackageName) const + { + const int32* Found = PackageToIndex.Find(PackageName); + return Found ? *Found : INDEX_NONE; + } + + const FAssetUsageNode& GetNode(int32 Index) const + { + return Nodes[Index]; + } + + TConstArrayView GetNodes() const + { + return Nodes; + } + + /** Packages this one depends on. */ + TConstArrayView GetDependencies(int32 Index) const; + + /** Packages that depend on this one. */ + TConstArrayView GetReferencers(int32 Index) const; + + /** Dense indices of every /Script/Engine.World package in the sweep. */ + TConstArrayView GetLevelIndices() const + { + return LevelIndices; + } + + const FAssetUsageGraphStats& GetStats() const + { + return Stats; + } + + /** + * Follow a redirector chain to the asset it ultimately points at. + * Returns Index unchanged when it is not a redirector. Cycle-safe. + */ + int32 ResolveRedirector(int32 Index) const; + +private: + int32 AddOrFindPackage(FName PackageName); + void BuildReverseEdges(); + + TArray Nodes; + TMap PackageToIndex; + + /** CSR forward edges: Dependencies[DependencyOffsets[i] .. DependencyOffsets[i+1]). */ + TArray Dependencies; + TArray DependencyOffsets; + + TArray Referencers; + TArray ReferencerOffsets; + + TArray LevelIndices; + + FAssetUsageGraphStats Stats; +}; diff --git a/Source/AssetUsageAuditCore/Public/AssetUsagePaths.h b/Source/AssetUsageAuditCore/Public/AssetUsagePaths.h new file mode 100644 index 0000000..8868423 --- /dev/null +++ b/Source/AssetUsageAuditCore/Public/AssetUsagePaths.h @@ -0,0 +1,81 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" + +/** + * Package-path predicates and conversions. + * + * Pure string work, no registry and no engine state, so it is unit-testable and cheap enough to + * call inside the per-asset sweep. + * + * Note on OFPA: these helpers only ever *classify* a path. They never try to derive a level's + * external-actor folder by string building, and they never try to recover the owning level from + * an external actor path. Both are real traps - Content Bundles inject /CB// and External + * Data Layers inject /EDL// between the folder and the level path, and plugins can register + * additional roots through delegates. Producing those paths is ULevel::GetExternalActorsPaths's + * job; recovering ownership is avoided entirely by traversing forward from the level. + */ +namespace AssetUsagePaths +{ + /** True for a package under an __ExternalActors__ or __ExternalObjects__ root. */ + ASSETUSAGEAUDITCORE_API bool IsExternalPackage(FName PackageName); + ASSETUSAGEAUDITCORE_API bool IsExternalPackage(FStringView PackagePath); + + /** True for /Script/... - code, not an asset. Cannot be exported or reported unused. */ + ASSETUSAGEAUDITCORE_API bool IsScriptPackage(FName PackageName); + ASSETUSAGEAUDITCORE_API bool IsScriptPackage(FStringView PackagePath); + + /** True for /Engine/... or /Temp/... - not project content. */ + ASSETUSAGEAUDITCORE_API bool IsEngineOrTempPackage(FStringView PackagePath); + + /** + * True when the package sits under any of the given exclusion prefixes. + * + * Prefixes may be given in either package form ("/Game/3rdParty") or content-relative form + * ("Content/3rdParty"), because the UI shows users the latter and settings files tend to + * accumulate both. Matching is case-insensitive and boundary-aware, so "/Game/Art" does not + * exclude "/Game/ArtSource". + */ + ASSETUSAGEAUDITCORE_API bool IsPathExcluded(FName PackageName, const TArray& ExcludedPrefixes); + ASSETUSAGEAUDITCORE_API bool IsPathExcluded(FStringView PackagePath, const TArray& ExcludedPrefixes); + + /** + * Normalise an exclusion prefix to package form with no trailing slash. + * "Content/3rdParty/" -> "/Game/3rdParty" + * "/Game/3rdParty" -> "/Game/3rdParty" + * "Content" -> "/Game" + */ + ASSETUSAGEAUDITCORE_API FString NormalizeExclusionPrefix(const FString& Prefix); + + /** + * Package name to a path relative to the project root, as the report requires. + * "/Game/Space/Art/SM_Rock" -> "Content/Space/Art/SM_Rock.uasset" + * + * @param bIsLevel Chooses the .umap extension over .uasset. + * @return Empty for packages with no project-relative form, e.g. /Script or /Engine. + */ + ASSETUSAGEAUDITCORE_API FString ToProjectRelativePath(FName PackageName, bool bIsLevel); + + /** + * Can this folder be used as a Migrate destination? + * + * Mirrors the two checks UAssetToolsImpl makes, and exists because it makes them *after* being + * called and reports the refusal to the Output Log alone - so an impossible export otherwise + * presents as a button that does nothing. + * + * The engine's rules: + * 1. the path must end in /Content/ (MigratePackages_ReportConfirmed); + * 2. the folder above it must hold a .uproject, or exactly one .uplugin + * (FPackageMigrationImpl::GetMountPointRootPath) - that is where the destination mount + * point comes from, and without it Migrate aborts. + * + * Lives in Core despite serving an editor-only feature: it is filesystem and string work with + * no AssetTools involved, so it belongs with the other path predicates and can be tested + * without an editor module. + * + * @return Reason the folder is unusable, or an empty string when it is fine. + */ + ASSETUSAGEAUDITCORE_API FString ValidateMigrateDestination(const FString& Directory); +} diff --git a/Source/AssetUsageAuditCore/Public/AssetUsageReportWriter.h b/Source/AssetUsageAuditCore/Public/AssetUsageReportWriter.h new file mode 100644 index 0000000..07d9c97 --- /dev/null +++ b/Source/AssetUsageAuditCore/Public/AssetUsageReportWriter.h @@ -0,0 +1,68 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" + +struct FAssetUsageAuditResult; + +/** + * Writes the audit result as JSON and CSV. + * + * Both come from one in-memory result in a single call, so the two files can never disagree + * about the same run. JSON is the machine-readable form (nested level lists, filter metadata); + * CSV is the flat form a human opens in Excel. + */ +class ASSETUSAGEAUDITCORE_API FAssetUsageReportWriter +{ +public: + struct FOptions + { + /** Absolute directory to write into. Created if missing. */ + FString OutputDirectory; + + /** Base filename without extension; ".json" and ".csv" are appended. */ + FString BaseFileName = TEXT("AssetUsageReport"); + + bool bWriteJson = true; + bool bWriteCsv = true; + + /** + * Write a UTF-8 BOM at the start of the CSV. + * + * Excel misreads UTF-8 without it and mangles every non-ASCII asset name, which on this + * project means the Cyrillic folder and asset names come out as garbage. + */ + bool bCsvUtf8Bom = true; + + /** CSV field separator. Semicolon suits locales where the comma is a decimal separator. */ + TCHAR CsvDelimiter = TEXT(';'); + + /** Separator for multi-valued fields inside one CSV cell, e.g. the level list. */ + FString CsvMultiValueSeparator = TEXT("|"); + }; + + struct FResult + { + bool bSuccess = false; + FString JsonPath; + FString CsvPath; + FString ErrorMessage; + }; + + static FResult Write(const FAssetUsageAuditResult& AuditResult, const FOptions& Options); + + /** Serialize to a JSON string without touching disk. Exposed for tests. */ + static FString BuildJson(const FAssetUsageAuditResult& AuditResult); + + /** Serialize to a CSV string without touching disk. Exposed for tests. */ + static FString BuildCsv(const FAssetUsageAuditResult& AuditResult, const FOptions& Options); + + /** + * Quote and escape one CSV field per RFC 4180. + * + * Exposed because this is where CSV writers usually break: a field containing the delimiter, + * a quote or a newline must be quoted, and embedded quotes doubled. + */ + static FString EscapeCsvField(const FString& Field, TCHAR Delimiter); +}; diff --git a/Source/AssetUsageAuditCore/Public/IndirectReferenceScanner.h b/Source/AssetUsageAuditCore/Public/IndirectReferenceScanner.h new file mode 100644 index 0000000..382b450 --- /dev/null +++ b/Source/AssetUsageAuditCore/Public/IndirectReferenceScanner.h @@ -0,0 +1,66 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" + +/** + * One /Game path literal found in a text file, with enough context to check it by hand. + */ +struct FIndirectReference +{ + /** Package name the literal resolved to, e.g. /Game/Space/Core/GameModes/BP_FirstPersonGameMode. */ + FName PackageName; + + /** Absolute path of the file the literal was found in. */ + FString SourceFile; + + /** 1-based line number. */ + int32 LineNumber = 0; + + /** True when the hit came from Config/, false when from Source/. */ + bool bFromConfig = false; + + FString ToProvenanceString() const; +}; + +/** + * Finds asset paths referenced from text rather than from a package. + * + * This exists because of a concrete, measured failure mode on this project: Config/DefaultEngine.ini + * names BP_FirstPersonGameMode, BP_MenuSystemGameInstance, RefinedMenuMap and Gyms_Geoda as plain + * strings. No asset references them, so the Asset Registry reports zero referencers and a naive + * audit calls the project's GameMode unused. + * + * It is a mitigation, not a solution. It cannot see a path assembled at runtime by concatenation, + * an OpenLevel(FName) call, or an FMOD event path - those stay Unknown, deliberately. + */ +class ASSETUSAGEAUDITCORE_API FIndirectReferenceScanner +{ +public: + struct FOptions + { + /** Absolute directories to scan. Defaults to /Config and /Source. */ + TArray Directories; + + /** File extensions to read, lowercase, with the dot. */ + TArray Extensions = { TEXT(".ini"), TEXT(".cpp"), TEXT(".h"), TEXT(".cs") }; + + /** Skip files larger than this; a multi-megabyte generated file is never a reference site. */ + int64 MaxFileSizeBytes = 8 * 1024 * 1024; + }; + + /** Scan and return every distinct package path found, with provenance. */ + static TArray Scan(const FOptions& Options); + + /** Default options: /Config and /Source. */ + static FOptions MakeDefaultOptions(); + + /** + * Extract /Game path literals from one line of text. + * + * Exposed for testing: the trailing-_C strip and the delimiter set are where this goes wrong. + * "/Game/X/BP_Y.BP_Y_C" and "/Game/X/BP_Y" must both yield the package /Game/X/BP_Y. + */ + static void ExtractGamePathsFromLine(FStringView Line, TArray& OutPackageNames); +}; diff --git a/Source/AssetUsageAuditCore/Public/LevelUsageResolver.h b/Source/AssetUsageAuditCore/Public/LevelUsageResolver.h new file mode 100644 index 0000000..97c3058 --- /dev/null +++ b/Source/AssetUsageAuditCore/Public/LevelUsageResolver.h @@ -0,0 +1,154 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" +#include "AssetUsageAuditTypes.h" +#include "Containers/BitArray.h" + +class FAssetUsageGraph; +class IAssetRegistry; + +struct FLevelUsageResolveOptions +{ + /** Levels to analyse. Empty means every /Script/Engine.World package in the graph. */ + TArray LevelPackages; + + /** + * Call ULevel::ScanLevelAssets before traversing each level. + * + * The external-actor dependency gatherer only reports packages the registry has already + * scanned. Skipping this can silently yield an empty external-actor set on a level nobody + * has opened this session - which looks exactly like a level that genuinely has no actors. + */ + bool bScanLevelAssetsFirst = true; + + /** + * Additionally seed the BFS from ULevel::GetExternalActorsPaths / GetExternalObjectsPaths. + * + * Belt and braces: the gatherer normally puts these edges in the graph already. Keeping the + * explicit seed means a stale or partially-scanned registry degrades to a slower correct + * answer instead of a fast wrong one, and it lets us tag ExternalActor provenance precisely. + */ + bool bSeedExternalPackages = true; + + /** + * Follow a reference into another map's contents. + * + * Off by default, and that default is load-bearing. A map does not only reference its own + * sublevels: anything it can reach may name an unrelated map, and on this project it does. + * WP_Main -> BP_FirstPersonGameMode -> PDA_MenuSystemConfig -> L_MainLevel drags in the whole + * of L_MainLevel, which measured at 9994 of WP_Main's 18136 rows - two thirds of the answer + * was another level's content. + * + * With this off, a foreign map is still reported as referenced, but its contents are attributed + * to that map alone. Genuine sublevels and Level Instances are unaffected: they are reached + * from the level package itself or from one of its external actor packages, and that crossing + * is always allowed. See ShouldCrossIntoLevel in the .cpp. + */ + bool bTraverseIntoOtherLevels = false; + + /** Record a human-readable route for each asset. Costs one int32 array per BFS. */ + bool bRecordRoutes = true; + + /** Route strings longer than this many hops are elided in the middle. */ + int32 MaxRouteHops = 12; + + /** Optional progress sink, called once per level with (LevelIndex, TotalLevels). */ + TFunction OnLevelProgress; + + /** Return true to abort the sweep between levels. */ + TFunction ShouldAbort; +}; + +struct FLevelUsageStats +{ + int32 LevelsScanned = 0; + int32 AssetsReachable = 0; + int32 ExternalPackagesSeeded = 0; + int32 RedirectorsResolved = 0; + + /** + * Foreign maps referenced but deliberately not expanded into. + * + * A non-zero value here is the amount of another level's content that would otherwise have + * been attributed to this one. Worth surfacing: on WP_Main it was two thirds of the report. + */ + int32 ForeignLevelsNotExpanded = 0; + double ScanLevelAssetsSeconds = 0.0; + double TraversalSeconds = 0.0; + + double TotalSeconds() const + { + return ScanLevelAssetsSeconds + TraversalSeconds; + } +}; + +/** + * Which levels reach which assets. + * + * Reachability is one TBitArray per level over dense package indices. At this project's scale + * that is 1207 levels x 80608 bits, roughly 12 MB - cheap enough to hold both query directions + * ("assets on this level" and "levels using this asset") without ever re-walking the graph. + */ +struct ASSETUSAGEAUDITCORE_API FLevelUsageResult +{ + /** Level package names, parallel to LevelReachability. */ + TArray LevelPackageNames; + + /** LevelReachability[L][A] - level L reaches asset A. */ + TArray> LevelReachability; + + /** Union across every level. The primary input to the UsedOnLevel verdict. */ + TBitArray<> ReachableFromAnyLevel; + + /** Accumulated provenance flags per asset index. */ + TArray Provenance; + + /** Incoming edge counts per asset index, split by the Hard property. */ + TArray HardReferenceCounts; + TArray SoftReferenceCounts; + + /** Route string per asset index, from the first level that reached it. Empty when not recorded. */ + TMap Routes; + + FLevelUsageStats Stats; + + /** Levels that reach the given asset index. */ + TArray GetLevelsForAsset(int32 AssetIndex) const; + + bool IsReachableFromAnyLevel(int32 AssetIndex) const + { + return ReachableFromAnyLevel.IsValidIndex(AssetIndex) && ReachableFromAnyLevel[AssetIndex]; + } +}; + +/** + * Forward traversal from level packages to everything they pull in. + * + * Direction matters. Going forward from the map is correct; going backward from an external actor + * package to its owning map is not, because PackageDependencyData.cpp:57-96 deliberately strips + * the UsedInGame flag off that reverse import by naming convention, so the AssetManager will not + * drag a whole map in when something references one actor. Reconstructing ownership from the path + * instead is possible but fragile - Content Bundles inject /CB// and External Data Layers + * inject /EDL// between the folder and the level path. Forward traversal sidesteps all of it. + */ +class ASSETUSAGEAUDITCORE_API FLevelUsageResolver +{ +public: + FLevelUsageResolver(const FAssetUsageGraph& InGraph, IAssetRegistry& InAssetRegistry); + + FLevelUsageResult Resolve(const FLevelUsageResolveOptions& Options); + +private: + /** BFS from one level. Marks OutReachable and accumulates provenance and counts. */ + void TraverseLevel(int32 LevelIndex, const FLevelUsageResolveOptions& Options, TBitArray<>& OutReachable, FLevelUsageResult& InOutResult, TArray& ScratchPredecessor, TArray& ScratchQueue); + + /** Dense indices of the __ExternalActors__ / __ExternalObjects__ packages owned by a level. */ + void GatherExternalPackageSeeds(int32 LevelIndex, TArray& OutSeeds, FLevelUsageResult& InOutResult) const; + + FString BuildRouteString(int32 LevelIndex, int32 AssetIndex, const TArray& Predecessor, int32 MaxHops) const; + + const FAssetUsageGraph& Graph; + IAssetRegistry& AssetRegistry; +}; diff --git a/Source/AssetUsageAuditEditor/AssetUsageAuditEditor.Build.cs b/Source/AssetUsageAuditEditor/AssetUsageAuditEditor.Build.cs new file mode 100644 index 0000000..6785129 --- /dev/null +++ b/Source/AssetUsageAuditEditor/AssetUsageAuditEditor.Build.cs @@ -0,0 +1,38 @@ +// NextGenium 2026. Asset Usage Audit. + +using UnrealBuildTool; + +public class AssetUsageAuditEditor : ModuleRules +{ + public AssetUsageAuditEditor(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicDependencyModuleNames.AddRange(new string[] + { + "Core", + "AssetUsageAuditCore" + }); + + // Everything editor-only and UI-shaped belongs here and nowhere else. Keeping this list + // out of AssetUsageAuditCore is what lets the same analysis run from a commandlet later. + PrivateDependencyModuleNames.AddRange(new string[] + { + "CoreUObject", + "Engine", + "AssetRegistry", + "AssetTools", // MigratePackages - the only path that keeps references working + "DeveloperSettings", + "DesktopPlatform", // native folder picker for the export destination + "EditorFramework", + "InputCore", + "Projects", + "Slate", + "SlateCore", + "ToolMenus", + "ToolWidgets", // SSearchBox, SSegmentedControl + "UnrealEd", + "WorkspaceMenuStructure" // places the tab under Tools in the Window menu + }); + } +} diff --git a/Source/AssetUsageAuditEditor/Private/AssetUsageAuditEditorModule.cpp b/Source/AssetUsageAuditEditor/Private/AssetUsageAuditEditorModule.cpp new file mode 100644 index 0000000..f0aa777 --- /dev/null +++ b/Source/AssetUsageAuditEditor/Private/AssetUsageAuditEditorModule.cpp @@ -0,0 +1,257 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetUsageAuditEditorModule.h" + +#include "AssetUsageAuditSettings.h" +#include "AssetUsageAuditor.h" +#include "AssetUsageReportWriter.h" +#include "SAssetUsageAuditPanel.h" +#include "AssetRegistry/AssetRegistryModule.h" +#include "AssetRegistry/IAssetRegistry.h" +#include "Framework/Docking/TabManager.h" +#include "HAL/IConsoleManager.h" +#include "Misc/ScopedSlowTask.h" +#include "Styling/AppStyle.h" +#include "ToolMenus.h" +#include "Widgets/Docking/SDockTab.h" +#include "WorkspaceMenuStructure.h" +#include "WorkspaceMenuStructureModule.h" + +DEFINE_LOG_CATEGORY(LogAssetUsageAuditEditor); + +#define LOCTEXT_NAMESPACE "FAssetUsageAuditEditorModule" + +const FName FAssetUsageAuditEditorModule::PanelTabId(TEXT("AssetUsageAudit")); + +void FAssetUsageAuditEditorModule::StartupModule() +{ + RegisterConsoleCommands(); + RegisterTabSpawner(); + + // ToolMenus may not exist yet at PostEngineInit; the callback fires once it does. Registering + // directly as well would double up the entry. + UToolMenus::RegisterStartupCallback( + FSimpleMulticastDelegate::FDelegate::CreateRaw(this, &FAssetUsageAuditEditorModule::RegisterMenus)); +} + +void FAssetUsageAuditEditorModule::ShutdownModule() +{ + UToolMenus::UnRegisterStartupCallback(this); + UToolMenus::UnregisterOwner(this); + + UnregisterTabSpawner(); + UnregisterConsoleCommands(); +} + +void FAssetUsageAuditEditorModule::RegisterTabSpawner() +{ + FGlobalTabmanager::Get() + ->RegisterNomadTabSpawner( + PanelTabId, + FOnSpawnTab::CreateRaw(this, &FAssetUsageAuditEditorModule::SpawnPanelTab)) + .SetDisplayName(LOCTEXT("PanelTabTitle", "Asset Usage Audit")) + .SetTooltipText(LOCTEXT("PanelTabTooltip", "Find which assets each level uses, and which are used nowhere.")) + .SetGroup(WorkspaceMenu::GetMenuStructure().GetToolsCategory()) + .SetIcon(FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.Search")); +} + +void FAssetUsageAuditEditorModule::UnregisterTabSpawner() +{ + FGlobalTabmanager::Get()->UnregisterNomadTabSpawner(PanelTabId); +} + +TSharedRef FAssetUsageAuditEditorModule::SpawnPanelTab(const FSpawnTabArgs& Args) +{ + return SNew(SDockTab) + .TabRole(ETabRole::NomadTab) + [ + SNew(SAssetUsageAuditPanel) + ]; +} + +void FAssetUsageAuditEditorModule::RegisterMenus() +{ + FToolMenuOwnerScoped OwnerScoped(this); + + UToolMenu* ToolsMenu = UToolMenus::Get()->ExtendMenu(TEXT("LevelEditor.MainMenu.Tools")); + if (!ToolsMenu) + { + return; + } + + FToolMenuSection& Section = ToolsMenu->FindOrAddSection(TEXT("Audit"), LOCTEXT("AuditSection", "Audit")); + + Section.AddMenuEntry( + TEXT("OpenAssetUsageAudit"), + LOCTEXT("OpenPanel", "Asset Usage Audit"), + LOCTEXT("OpenPanelTip", "Find which assets each level uses, and which are used nowhere."), + FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.Search"), + FUIAction(FExecuteAction::CreateLambda([]() + { + FGlobalTabmanager::Get()->TryInvokeTab(FAssetUsageAuditEditorModule::PanelTabId); + }))); +} + +void FAssetUsageAuditEditorModule::RegisterConsoleCommands() +{ + IConsoleManager& ConsoleManager = IConsoleManager::Get(); + + ConsoleCommands.Add(ConsoleManager.RegisterConsoleCommand( + TEXT("AssetUsageAudit.Run"), + TEXT("Audit asset usage across levels and write a JSON + CSV report. Optional args: level package names to restrict the sweep."), + FConsoleCommandWithArgsDelegate::CreateLambda([this](const TArray& Args) + { + ExecuteAudit(Args, /*bOnlyUnused=*/false); + }), + ECVF_Default)); + + ConsoleCommands.Add(ConsoleManager.RegisterConsoleCommand( + TEXT("AssetUsageAudit.OpenPanel"), + TEXT("Open the Asset Usage Audit panel. Same as Tools > Asset Usage Audit."), + FConsoleCommandDelegate::CreateLambda([]() + { + if (FGlobalTabmanager::Get()->TryInvokeTab(PanelTabId).IsValid()) + { + UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Asset Usage Audit panel opened.")); + } + else + { + UE_LOG(LogAssetUsageAuditEditor, Warning, TEXT("Could not open the Asset Usage Audit panel.")); + } + }), + ECVF_Default)); + + ConsoleCommands.Add(ConsoleManager.RegisterConsoleCommand( + TEXT("AssetUsageAudit.FindUnused"), + TEXT("Report only assets that no level reaches. Read the Verdict column: Unknown is not the same as unused."), + FConsoleCommandWithArgsDelegate::CreateLambda([this](const TArray& Args) + { + ExecuteAudit(Args, /*bOnlyUnused=*/true); + }), + ECVF_Default)); +} + +void FAssetUsageAuditEditorModule::UnregisterConsoleCommands() +{ + IConsoleManager& ConsoleManager = IConsoleManager::Get(); + + for (IConsoleObject* Command : ConsoleCommands) + { + if (Command) + { + ConsoleManager.UnregisterConsoleObject(Command); + } + } + + ConsoleCommands.Reset(); +} + +void FAssetUsageAuditEditorModule::ExecuteAudit(const TArray& Args, bool bOnlyUnused) +{ + FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")); + IAssetRegistry& AssetRegistry = AssetRegistryModule.Get(); + + // A sweep started mid-scan silently reports a fraction of the project, so the scan must finish + // first. Block rather than refuse: on a cold start the registry is nearly always still + // scanning, and refusing made the command unusable from -ExecCmds, where there is no human to + // retry. WaitForCompletion is synchronous and behaves the same in the editor and a commandlet. + if (AssetRegistry.IsLoadingAssets()) + { + UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Asset Registry is still scanning; waiting for it to finish before auditing.")); + AssetRegistry.WaitForCompletion(); + UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Asset Registry scan complete; starting the audit.")); + } + + const UAssetUsageAuditSettings* Settings = UAssetUsageAuditSettings::Get(); + check(Settings); + + FAssetUsageAuditRequest Request; + Request.IncludePackagePaths = Settings->IncludedPackagePaths; + Request.ExcludePackagePaths = Settings->ExcludedPackagePaths; + Request.bScanIndirectReferences = Settings->bScanIndirectReferences; + Request.bHideExternalPackages = Settings->bHideExternalPackages; + Request.bOnlyUnusedAssets = bOnlyUnused; + + for (const FString& Arg : Args) + { + Request.LevelPackages.Add(FName(*Arg)); + } + + FScopedSlowTask SlowTask(100.0f, LOCTEXT("AuditingAssets", "Auditing asset usage...")); + SlowTask.MakeDialog(/*bShowCancelButton=*/true); + + bool bCancelled = false; + + Request.ResolveOptions.ShouldAbort = [&SlowTask, &bCancelled]() + { + if (SlowTask.ShouldCancel()) + { + bCancelled = true; + return true; + } + return false; + }; + + Request.ResolveOptions.OnLevelProgress = [&SlowTask](int32 LevelOrdinal, int32 TotalLevels) + { + if (TotalLevels > 0 && LevelOrdinal % 16 == 0) + { + SlowTask.EnterProgressFrame( + 16.0f * 100.0f / static_cast(TotalLevels), + FText::Format(LOCTEXT("AuditingLevel", "Level {0} of {1}"), LevelOrdinal, TotalLevels)); + } + }; + + const FAssetUsageAuditResult Result = FAssetUsageAuditor::Run(AssetRegistry, Request); + + if (bCancelled) + { + UE_LOG(LogAssetUsageAuditEditor, Warning, + TEXT("Audit cancelled. Partial results were discarded - a partial sweep understates usage and must not be reported.")); + return; + } + + FAssetUsageReportWriter::FOptions WriteOptions; + WriteOptions.OutputDirectory = Settings->GetResolvedExportDirectory(); + WriteOptions.BaseFileName = FString::Printf(TEXT("AssetUsageReport_%s"), *Result.Header.GeneratedAt.ToString(TEXT("%Y%m%d_%H%M%S"))); + + const FAssetUsageReportWriter::FResult WriteResult = FAssetUsageReportWriter::Write(Result, WriteOptions); + + if (!WriteResult.bSuccess) + { + UE_LOG(LogAssetUsageAuditEditor, Error, TEXT("Report was not written: %s"), *WriteResult.ErrorMessage); + return; + } + + UE_LOG(LogAssetUsageAuditEditor, Display, + TEXT("Audit finished in %.2fs. %d rows across %d levels (%d packages, %d edges, %d OFPA packages).\n") + TEXT(" UsedOnLevel=%d UsedByAssetsOnly=%d ReferencedFromConfigOrSource=%d Unreferenced=%d Unknown=%d\n") + TEXT(" JSON: %s\n CSV: %s"), + Result.Header.ScanDurationSeconds, + Result.Rows.Num(), + Result.Header.LevelsScanned, + Result.GraphStats.NumPackages, + Result.GraphStats.NumEdges, + Result.GraphStats.NumExternalPackages, + Result.CountByVerdict(EAssetUsageVerdict::UsedOnLevel), + Result.CountByVerdict(EAssetUsageVerdict::UsedByAssetsOnly), + Result.CountByVerdict(EAssetUsageVerdict::ReferencedFromConfigOrSource), + Result.CountByVerdict(EAssetUsageVerdict::Unreferenced), + Result.CountByVerdict(EAssetUsageVerdict::Unknown), + *WriteResult.JsonPath, + *WriteResult.CsvPath); + + // The canary. If a sweep of an OFPA level yields almost nothing, the dependency query has + // regressed to Hard-only and every external actor has silently vanished from the result. + if (Result.GraphStats.NumExternalPackages > 0 && Result.LevelStats.AssetsReachable < Result.GraphStats.NumExternalPackages / 4) + { + UE_LOG(LogAssetUsageAuditEditor, Warning, + TEXT("Only %d assets were reachable although the project has %d One File Per Actor packages. ") + TEXT("This is the signature of a Hard-only dependency query dropping every external actor. Check the traversal query."), + Result.LevelStats.AssetsReachable, Result.GraphStats.NumExternalPackages); + } +} + +#undef LOCTEXT_NAMESPACE + +IMPLEMENT_MODULE(FAssetUsageAuditEditorModule, AssetUsageAuditEditor) diff --git a/Source/AssetUsageAuditEditor/Private/AssetUsageAuditLibrary.cpp b/Source/AssetUsageAuditEditor/Private/AssetUsageAuditLibrary.cpp new file mode 100644 index 0000000..4ac792d --- /dev/null +++ b/Source/AssetUsageAuditEditor/Private/AssetUsageAuditLibrary.cpp @@ -0,0 +1,213 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetUsageAuditLibrary.h" + +#include "AssetUsageAuditEditorModule.h" +#include "AssetUsageAuditSettings.h" +#include "AssetUsageAuditor.h" +#include "AssetUsageReportWriter.h" +#include "AssetRegistry/AssetRegistryModule.h" +#include "AssetRegistry/IAssetRegistry.h" +#include "HAL/PlatformTime.h" +#include "Misc/ScopedSlowTask.h" + +namespace +{ + /** + * The registry gathers asynchronously in the editor. Reading it mid-gather yields a partial + * graph and therefore a confidently wrong "unused" list, so block until it is complete. + * In a commandlet the module already gathers synchronously and this returns immediately. + */ + IAssetRegistry& GetReadyAssetRegistry() + { + FAssetRegistryModule& Module = FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")); + IAssetRegistry& AssetRegistry = Module.Get(); + + if (AssetRegistry.IsLoadingAssets()) + { + UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Asset Registry is still scanning; waiting before the audit.")); + AssetRegistry.WaitForCompletion(); + } + + return AssetRegistry; + } + + /** Populate a request from the shared settings, so every entry point behaves the same. */ + FAssetUsageAuditRequest MakeRequestFromSettings() + { + FAssetUsageAuditRequest Request; + + if (const UAssetUsageAuditSettings* Settings = UAssetUsageAuditSettings::Get()) + { + Request.IncludePackagePaths = Settings->IncludedPackagePaths; + Request.ExcludePackagePaths = Settings->ExcludedPackagePaths; + Request.bScanIndirectReferences = Settings->bScanIndirectReferences; + Request.bHideExternalPackages = Settings->bHideExternalPackages; + } + + return Request; + } +} + +bool UAssetUsageAuditLibrary::RunAudit(const TArray& LevelPackageNames, const FString& OutputDirectory, FString& OutReportPath) +{ + OutReportPath.Reset(); + + IAssetRegistry& AssetRegistry = GetReadyAssetRegistry(); + + FAssetUsageAuditRequest Request = MakeRequestFromSettings(); + + Request.LevelPackages.Reserve(LevelPackageNames.Num()); + for (const FString& LevelName : LevelPackageNames) + { + Request.LevelPackages.Add(FName(*LevelName)); + } + + FScopedSlowTask SlowTask(100.0f, NSLOCTEXT("AssetUsageAudit", "RunningAudit", "Auditing asset usage...")); + SlowTask.MakeDialog(true); + + Request.ResolveOptions.OnLevelProgress = [&SlowTask](int32 Current, int32 Total) + { + if (Total > 0) + { + SlowTask.EnterProgressFrame(100.0f / static_cast(Total)); + } + }; + + Request.ResolveOptions.ShouldAbort = [&SlowTask]() + { + return SlowTask.ShouldCancel(); + }; + + const FAssetUsageAuditResult Result = FAssetUsageAuditor::Run(AssetRegistry, Request); + + FAssetUsageReportWriter::FOptions WriteOptions; + WriteOptions.OutputDirectory = OutputDirectory; + + if (WriteOptions.OutputDirectory.IsEmpty()) + { + if (const UAssetUsageAuditSettings* Settings = UAssetUsageAuditSettings::Get()) + { + WriteOptions.OutputDirectory = Settings->GetResolvedExportDirectory(); + } + } + + const FAssetUsageReportWriter::FResult WriteResult = FAssetUsageReportWriter::Write(Result, WriteOptions); + + if (!WriteResult.bSuccess) + { + UE_LOG(LogAssetUsageAuditEditor, Error, TEXT("Failed to write the audit report: %s"), *WriteResult.ErrorMessage); + return false; + } + + OutReportPath = WriteResult.JsonPath; + + UE_LOG(LogAssetUsageAuditEditor, Log, + TEXT("Audit complete: %d rows across %d levels. UsedOnLevel %d, UsedByAssetsOnly %d, Config/Source %d, Unreferenced %d, Unknown %d. Reports: %s | %s"), + Result.Rows.Num(), + Result.LevelStats.LevelsScanned, + Result.CountByVerdict(EAssetUsageVerdict::UsedOnLevel), + Result.CountByVerdict(EAssetUsageVerdict::UsedByAssetsOnly), + Result.CountByVerdict(EAssetUsageVerdict::ReferencedFromConfigOrSource), + Result.CountByVerdict(EAssetUsageVerdict::Unreferenced), + Result.CountByVerdict(EAssetUsageVerdict::Unknown), + *WriteResult.JsonPath, + *WriteResult.CsvPath); + + return true; +} + +TArray UAssetUsageAuditLibrary::GetAssetsUsedOnLevel(const FString& LevelPackageName) +{ + TArray Out; + + if (LevelPackageName.IsEmpty()) + { + UE_LOG(LogAssetUsageAuditEditor, Warning, TEXT("GetAssetsUsedOnLevel called with an empty level name.")); + return Out; + } + + IAssetRegistry& AssetRegistry = GetReadyAssetRegistry(); + + FAssetUsageAuditRequest Request = MakeRequestFromSettings(); + Request.LevelPackages.Add(FName(*LevelPackageName)); + + // Routes are the expensive part of the sweep and nothing here consumes them. + Request.ResolveOptions.bRecordRoutes = false; + + const FAssetUsageAuditResult Result = FAssetUsageAuditor::Run(AssetRegistry, Request); + + Out.Reserve(Result.Rows.Num()); + for (const FAssetUsageRow& Row : Result.Rows) + { + if (Row.IsUsedOnAnyLevel()) + { + Out.Add(Row.PackageName.ToString()); + } + } + + return Out; +} + +TArray UAssetUsageAuditLibrary::GetLevelsUsingAsset(const FString& AssetPackageName) +{ + TArray Out; + + if (AssetPackageName.IsEmpty()) + { + UE_LOG(LogAssetUsageAuditEditor, Warning, TEXT("GetLevelsUsingAsset called with an empty asset name.")); + return Out; + } + + IAssetRegistry& AssetRegistry = GetReadyAssetRegistry(); + + // Every level must be swept: the question is which of them reach this asset, and that cannot + // be answered from a subset. + FAssetUsageAuditRequest Request = MakeRequestFromSettings(); + Request.ResolveOptions.bRecordRoutes = false; + + const FAssetUsageAuditResult Result = FAssetUsageAuditor::Run(AssetRegistry, Request); + + const FName Target(*AssetPackageName); + for (const FAssetUsageRow& Row : Result.Rows) + { + if (Row.PackageName == Target) + { + Out.Reserve(Row.Levels.Num()); + for (FName Level : Row.Levels) + { + Out.Add(Level.ToString()); + } + break; + } + } + + return Out; +} + +float UAssetUsageAuditLibrary::MeasureFullSweepSeconds() +{ + IAssetRegistry& AssetRegistry = GetReadyAssetRegistry(); + + FAssetUsageAuditRequest Request = MakeRequestFromSettings(); + + const double StartTime = FPlatformTime::Seconds(); + const FAssetUsageAuditResult Result = FAssetUsageAuditor::Run(AssetRegistry, Request); + const double Elapsed = FPlatformTime::Seconds() - StartTime; + + UE_LOG(LogAssetUsageAuditEditor, Log, + TEXT("Full sweep timing: %.2fs total. Graph: enumerate %.2fs + dependencies %.2fs over %d packages and %d edges. ") + TEXT("Levels: %d scanned, ScanLevelAssets %.2fs + traversal %.2fs, %d assets reachable, %d external packages seeded."), + Elapsed, + Result.GraphStats.EnumerateSeconds, + Result.GraphStats.DependencySeconds, + Result.GraphStats.NumPackages, + Result.GraphStats.NumEdges, + Result.LevelStats.LevelsScanned, + Result.LevelStats.ScanLevelAssetsSeconds, + Result.LevelStats.TraversalSeconds, + Result.LevelStats.AssetsReachable, + Result.LevelStats.ExternalPackagesSeeded); + + return static_cast(Elapsed); +} diff --git a/Source/AssetUsageAuditEditor/Private/AssetUsageAuditSettings.cpp b/Source/AssetUsageAuditEditor/Private/AssetUsageAuditSettings.cpp new file mode 100644 index 0000000..3c1c21a --- /dev/null +++ b/Source/AssetUsageAuditEditor/Private/AssetUsageAuditSettings.cpp @@ -0,0 +1,47 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetUsageAuditSettings.h" + +#include "Misc/App.h" +#include "Misc/Paths.h" + +UAssetUsageAuditSettings::UAssetUsageAuditSettings() +{ + // Bought content packs. Measured on this project: roughly two thirds of the 80k assets are + // third-party, so leaving these in makes the first run unreadable for an artist looking for + // their own content. + // + // Deliberately NOT excluded, despite being a bought pack: Content/MenuSystemPro. It supplies + // BP_MenuSystemGameInstance, which DefaultEngine.ini names as the project's GameInstance. + // Excluding it would hide a load-bearing asset from every report. + ExcludedPackagePaths = { + TEXT("Content/3rdParty"), + TEXT("Content/StarterContent"), + TEXT("Content/StarterBundle"), + TEXT("Content/Megascans"), + TEXT("Content/MSPresets") + }; +} + +const UAssetUsageAuditSettings* UAssetUsageAuditSettings::Get() +{ + return GetDefault(); +} + +FString UAssetUsageAuditSettings::GetResolvedExportDirectory() const +{ + if (!DefaultExportDirectory.IsEmpty()) + { + return DefaultExportDirectory; + } + + return FPaths::ConvertRelativePathToFull(FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("AssetUsageAudit"))); +} + +FName UAssetUsageAuditSettings::GetCategoryName() const +{ + // House convention, matching ConventionKeeper: project tools group under the project's own + // name in Project Settings rather than scattering across Plugins. + // GetProjectName already returns const TCHAR*; dereferencing it would pass a single character. + return FName(FApp::GetProjectName()); +} diff --git a/Source/AssetUsageAuditEditor/Private/AssetUsageAuditUserSettings.cpp b/Source/AssetUsageAuditEditor/Private/AssetUsageAuditUserSettings.cpp new file mode 100644 index 0000000..3804326 --- /dev/null +++ b/Source/AssetUsageAuditEditor/Private/AssetUsageAuditUserSettings.cpp @@ -0,0 +1,8 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetUsageAuditUserSettings.h" + +UAssetUsageAuditUserSettings* UAssetUsageAuditUserSettings::Get() +{ + return GetMutableDefault(); +} diff --git a/Source/AssetUsageAuditEditor/Private/SAssetExportDialog.cpp b/Source/AssetUsageAuditEditor/Private/SAssetExportDialog.cpp new file mode 100644 index 0000000..2da021c --- /dev/null +++ b/Source/AssetUsageAuditEditor/Private/SAssetExportDialog.cpp @@ -0,0 +1,532 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "SAssetExportDialog.h" + +#include "AssetUsageAuditEditorModule.h" +#include "AssetUsagePaths.h" +#include "DesktopPlatformModule.h" +#include "Framework/Application/SlateApplication.h" +#include "IDesktopPlatform.h" +#include "Widgets/Input/SButton.h" +#include "Widgets/Input/SCheckBox.h" +#include "Widgets/Input/SComboBox.h" +#include "Widgets/Input/SEditableTextBox.h" +#include "Widgets/Input/SSegmentedControl.h" +#include "Widgets/Layout/SBorder.h" +#include "Widgets/Layout/SGridPanel.h" +#include "Widgets/Layout/SSeparator.h" +#include "Widgets/SBoxPanel.h" +#include "Widgets/SWindow.h" +#include "Widgets/Text/STextBlock.h" + +#define LOCTEXT_NAMESPACE "AssetExportDialog" + +namespace +{ + FText LayoutLabel(EExportLayout Layout) + { + switch (Layout) + { + case EExportLayout::Flat: return LOCTEXT("LayoutFlat", "Flat - everything in one folder"); + case EExportLayout::MirrorTree: return LOCTEXT("LayoutMirror", "Mirror the content tree"); + case EExportLayout::FolderPerAsset: return LOCTEXT("LayoutPerAsset", "One folder per asset"); + case EExportLayout::Migrate: return LOCTEXT("LayoutMigrate", "Migrate into another Unreal project"); + } + + return FText::GetEmpty(); + } + + FText LayoutTooltip(EExportLayout Layout) + { + switch (Layout) + { + case EExportLayout::Flat: + return LOCTEXT("LayoutFlatTip", "Every file side by side in the destination. Two assets with the same name collide, which is what the naming policy below is for."); + + case EExportLayout::MirrorTree: + return LOCTEXT("LayoutMirrorTip", "Recreate the /Game folder structure under the destination. Collisions become almost impossible, but you get a deep tree."); + + case EExportLayout::FolderPerAsset: + return LOCTEXT("LayoutPerAssetTip", "Each ticked asset gets its own folder with the assets it references beside it. Anything shared is copied into every folder that needs it, so the file count is higher than the number of assets."); + + case EExportLayout::Migrate: + return LOCTEXT("LayoutMigrateTip", "Hand the transfer to Unreal's own Migrate. Choose the destination project's Content folder. This is the only option whose result opens in Unreal with references intact - the others produce files for people, not for the engine."); + } + + return FText::GetEmpty(); + } +} + +FText ValidateMigrateDestination(const FString& Directory) +{ + // The rule itself lives in Core with the other path predicates, where it can be tested without + // an editor module. This is only the wrapper that turns it into displayable text. + const FString Reason = AssetUsagePaths::ValidateMigrateDestination(Directory); + + return Reason.IsEmpty() ? FText::GetEmpty() : FText::FromString(Reason); +} + +/** + * The dialog body. + * + * Holds its own copy of the request and writes it back only when Export is pressed, so cancelling + * genuinely changes nothing - including the persisted defaults. + */ +class SAssetExportDialog : public SCompoundWidget +{ +public: + SLATE_BEGIN_ARGS(SAssetExportDialog) {} + SLATE_ARGUMENT(FAssetExportRequest, InitialRequest) + SLATE_ARGUMENT(int32, TickedCount) + SLATE_ARGUMENT(TSharedPtr, ParentWindow) + SLATE_END_ARGS() + + void Construct(const FArguments& InArgs) + { + Request = InArgs._InitialRequest; + TickedCount = InArgs._TickedCount; + ParentWindow = InArgs._ParentWindow; + + for (EExportLayout Layout : { EExportLayout::Flat, EExportLayout::MirrorTree, EExportLayout::FolderPerAsset, EExportLayout::Migrate }) + { + LayoutOptions.Add(MakeShared(Layout)); + } + + ChildSlot + [ + SNew(SBorder) + .BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder")) + .Padding(12.0f) + [ + SNew(SVerticalBox) + + + SVerticalBox::Slot() + .AutoHeight() + .Padding(0.0f, 0.0f, 0.0f, 8.0f) + [ + SNew(STextBlock) + .Text(FText::Format( + LOCTEXT("TickedHeader", "{0} asset(s) ticked."), + FText::AsNumber(TickedCount))) + ] + + + SVerticalBox::Slot() + .AutoHeight() + .Padding(0.0f, 0.0f, 0.0f, 8.0f) + [ + SNew(SSegmentedControl) + .Value_Lambda([this]() { return Request.Mode; }) + .OnValueChanged_Lambda([this](EFileExportMode NewMode) + { + Request.Mode = NewMode; + + // Migrate transfers packages; there is nothing to convert. Rather than + // ignore one of the two settings at export time, drop back to Flat here so + // the window always shows what will actually happen. + if (NewMode == EFileExportMode::ConvertToExchangeFormat && Request.Layout == EExportLayout::Migrate) + { + Request.Layout = EExportLayout::Flat; + } + }) + + + SSegmentedControl::Slot(EFileExportMode::CopyPackages) + .Text(LOCTEXT("ModeCopy", "Copy .uasset")) + .ToolTip(LOCTEXT("ModeCopyTip", "Byte-exact copies of the package files. Fast; loads nothing. Levels bring their One File Per Actor packages along.")) + + + SSegmentedControl::Slot(EFileExportMode::ConvertToExchangeFormat) + .Text(LOCTEXT("ModeConvert", "Convert (FBX/PNG/WAV)")) + .ToolTip(LOCTEXT("ModeConvertTip", "Through the engine's exporters. Loads every asset, so expect minutes rather than seconds. Classes with no configured format are skipped and counted.")) + ] + + + SVerticalBox::Slot() + .AutoHeight() + .Padding(0.0f, 0.0f, 0.0f, 8.0f) + [ + SNew(SSeparator) + ] + + + SVerticalBox::Slot() + .AutoHeight() + [ + MakeOptionsGrid() + ] + + + SVerticalBox::Slot() + .AutoHeight() + .Padding(0.0f, 12.0f, 0.0f, 0.0f) + [ + SNew(SHorizontalBox) + + + SHorizontalBox::Slot() + .FillWidth(1.0f) + [ + SNew(STextBlock) + .AutoWrapText(true) + .ColorAndOpacity(FSlateColor::UseSubduedForeground()) + .Text_Lambda([this]() { return GetFootnoteText(); }) + ] + + + SHorizontalBox::Slot() + .AutoWidth() + .Padding(8.0f, 0.0f, 0.0f, 0.0f) + [ + SNew(SButton) + .Text(LOCTEXT("Export", "Export")) + // Disabled rather than allowed-and-then-failed. Migrate reports a bad + // destination only to the Output Log, so pressing Export would look like a + // button that does nothing at all. + .IsEnabled_Lambda([this]() { return GetBlockingReason().IsEmpty(); }) + .ToolTipText_Lambda([this]() + { + const FText Reason = GetBlockingReason(); + return Reason.IsEmpty() + ? LOCTEXT("ExportTip", "Start the export. The exact file count is confirmed once more before anything is written.") + : Reason; + }) + .OnClicked(this, &SAssetExportDialog::OnAccept) + ] + + + SHorizontalBox::Slot() + .AutoWidth() + .Padding(4.0f, 0.0f, 0.0f, 0.0f) + [ + SNew(SButton) + .Text(LOCTEXT("Cancel", "Cancel")) + .OnClicked(this, &SAssetExportDialog::OnCancel) + ] + ] + ] + ]; + } + + bool WasAccepted() const + { + return bAccepted; + } + + const FAssetExportRequest& GetRequest() const + { + return Request; + } + +private: + TSharedRef MakeOptionsGrid() + { + TSharedRef Grid = SNew(SGridPanel).FillColumn(1, 1.0f); + + int32 Row = 0; + + // --- Destination --------------------------------------------------------------------- + Grid->AddSlot(0, Row).Padding(0.0f, 4.0f) + [ + SNew(STextBlock).Text(LOCTEXT("Destination", "Destination")) + ]; + + Grid->AddSlot(1, Row).Padding(8.0f, 4.0f) + [ + SNew(SHorizontalBox) + + + SHorizontalBox::Slot() + .FillWidth(1.0f) + [ + SNew(SEditableTextBox) + .Text_Lambda([this]() { return FText::FromString(Request.TargetDirectory); }) + .OnTextCommitted_Lambda([this](const FText& NewText, ETextCommit::Type) + { + Request.TargetDirectory = NewText.ToString(); + }) + .HintText(LOCTEXT("DestHint", "Choose a folder...")) + ] + + + SHorizontalBox::Slot() + .AutoWidth() + .Padding(4.0f, 0.0f, 0.0f, 0.0f) + [ + SNew(SButton) + .Text(LOCTEXT("Browse", "Browse...")) + .OnClicked(this, &SAssetExportDialog::OnBrowse) + ] + ]; + + ++Row; + + // --- Layout -------------------------------------------------------------------------- + Grid->AddSlot(0, Row).Padding(0.0f, 4.0f) + [ + SNew(STextBlock).Text(LOCTEXT("Layout", "Folder layout")) + ]; + + Grid->AddSlot(1, Row).Padding(8.0f, 4.0f) + [ + SNew(SComboBox>) + .OptionsSource(&LayoutOptions) + .OnGenerateWidget_Lambda([](TSharedPtr Item) + { + return SNew(STextBlock) + .Text(LayoutLabel(*Item)) + .ToolTipText(LayoutTooltip(*Item)); + }) + .OnSelectionChanged_Lambda([this](TSharedPtr Item, ESelectInfo::Type) + { + if (!Item.IsValid()) + { + return; + } + + Request.Layout = *Item; + + // The mirror of the rule in the mode switch above: picking Migrate while Convert is + // selected would leave two settings that contradict each other. + if (Request.Layout == EExportLayout::Migrate) + { + Request.Mode = EFileExportMode::CopyPackages; + + // The destination carried over from the other layouts is this project's + // Saved/AssetUsageAudit, which can never be a valid Migrate target. Leaving it + // in place would present a filled-in path that is guaranteed to fail; clearing + // it makes the next step obvious. + if (!ValidateMigrateDestination(Request.TargetDirectory).IsEmpty()) + { + Request.TargetDirectory.Reset(); + } + } + }) + [ + SNew(STextBlock) + .Text_Lambda([this]() { return LayoutLabel(Request.Layout); }) + .ToolTipText_Lambda([this]() { return LayoutTooltip(Request.Layout); }) + ] + ]; + + ++Row; + + // --- Group by type ------------------------------------------------------------------- + Grid->AddSlot(1, Row).Padding(8.0f, 4.0f) + [ + SNew(SCheckBox) + // Disabled rather than hidden: a checkbox that vanishes reads as a bug, whereas a + // greyed one with this tooltip explains which layout to pick to use it. + .IsEnabled_Lambda([this]() { return Request.Layout == EExportLayout::FolderPerAsset; }) + .IsChecked_Lambda([this]() + { + return Request.bGroupDependenciesByType ? ECheckBoxState::Checked : ECheckBoxState::Unchecked; + }) + .OnCheckStateChanged_Lambda([this](ECheckBoxState NewState) + { + Request.bGroupDependenciesByType = (NewState == ECheckBoxState::Checked); + }) + .ToolTipText(LOCTEXT("GroupByTypeTip", "Inside each asset's folder, put its dependencies into subfolders by type - Texture, Material, StaticMesh. The asset itself stays at the top of its folder. Only available with the one-folder-per-asset layout.")) + [ + SNew(STextBlock).Text(LOCTEXT("GroupByType", "Sort dependencies into type subfolders")) + ] + ]; + + ++Row; + + // --- Dependencies -------------------------------------------------------------------- + Grid->AddSlot(1, Row).Padding(8.0f, 4.0f) + [ + SNew(SCheckBox) + .IsChecked_Lambda([this]() + { + return Request.bIncludeDependencies ? ECheckBoxState::Checked : ECheckBoxState::Unchecked; + }) + .OnCheckStateChanged_Lambda([this](ECheckBoxState NewState) + { + Request.bIncludeDependencies = (NewState == ECheckBoxState::Checked); + }) + .ToolTipText(LOCTEXT("DepsTip", "Also export the materials, textures and other assets the ticked ones reference. Without this a mesh arrives without its materials and opens grey.")) + [ + SNew(STextBlock).Text(LOCTEXT("Deps", "Include referenced assets")) + ] + ]; + + ++Row; + + // --- Manifest ------------------------------------------------------------------------ + Grid->AddSlot(1, Row).Padding(8.0f, 4.0f) + [ + SNew(SCheckBox) + .IsEnabled_Lambda([this]() { return Request.Layout != EExportLayout::Migrate; }) + .IsChecked_Lambda([this]() + { + return Request.bWriteManifest ? ECheckBoxState::Checked : ECheckBoxState::Unchecked; + }) + .OnCheckStateChanged_Lambda([this](ECheckBoxState NewState) + { + Request.bWriteManifest = (NewState == ECheckBoxState::Checked); + }) + .ToolTipText(LOCTEXT("ManifestTip", "Write AssetUsageAudit.manifest.json beside the files: which package each file came from, and what it referenced. These layouts lose that information, and the manifest is what an importer would need to put it back. Not applicable to Migrate, which keeps the references itself.")) + [ + SNew(STextBlock).Text(LOCTEXT("Manifest", "Write dependency manifest")) + ] + ]; + + ++Row; + + // --- Collision ----------------------------------------------------------------------- + Grid->AddSlot(0, Row).Padding(0.0f, 4.0f) + [ + SNew(STextBlock).Text(LOCTEXT("Collision", "If a file exists")) + ]; + + Grid->AddSlot(1, Row).Padding(8.0f, 4.0f) + [ + SNew(SSegmentedControl) + .Value_Lambda([this]() { return Request.CollisionPolicy; }) + .OnValueChanged_Lambda([this](EExportCollisionPolicy NewPolicy) { Request.CollisionPolicy = NewPolicy; }) + + + SSegmentedControl::Slot(EExportCollisionPolicy::Index) + .Text(LOCTEXT("PolicyIndex", "Keep both")) + .ToolTip(LOCTEXT("PolicyIndexTip", "Write Foo_1, Foo_2 and so on. An existing index is incremented rather than having a second one appended: Foo_7 becomes Foo_8.")) + + + SSegmentedControl::Slot(EExportCollisionPolicy::Overwrite) + .Text(LOCTEXT("PolicyOverwrite", "Overwrite")) + .ToolTip(LOCTEXT("PolicyOverwriteTip", "Replace the existing file. Cannot be undone.")) + ]; + + return Grid; + } + + /** Why Export is unavailable, or empty when it is available. */ + FText GetBlockingReason() const + { + if (Request.TargetDirectory.IsEmpty()) + { + return LOCTEXT("NeedDir", "Choose a destination folder first."); + } + + if (Request.Layout == EExportLayout::Migrate) + { + return ValidateMigrateDestination(Request.TargetDirectory); + } + + return FText::GetEmpty(); + } + + FText GetFootnoteText() const + { + // A blocking reason outranks the explanatory footnote: the user needs to know why they + // cannot proceed before they need to know how the layout behaves. + const FText Blocking = GetBlockingReason(); + if (!Blocking.IsEmpty()) + { + return Blocking; + } + + if (Request.Layout == EExportLayout::Migrate) + { + return LOCTEXT("FootnoteMigrate", + "Choose the destination project's Content folder. Unreal performs the transfer and reports the result itself."); + } + + // The single most useful thing this window can say. References inside a .uasset are full + // package paths, so a file only resolves when it sits at the same path in the destination - + // and neither of these two layouts puts it there. + if (Request.Layout == EExportLayout::FolderPerAsset) + { + return LOCTEXT("FootnotePerAsset", + "Shared assets are copied into every folder that needs them, so more files are written than there are assets. " + "These files are for people, not for Unreal: copied into a project they open with broken references. Use Migrate for that."); + } + + if (Request.Layout == EExportLayout::Flat) + { + return LOCTEXT("FootnoteFlat", + "These files are for people, not for Unreal: copied into a project they open with broken references. Use Migrate for that."); + } + + if (Request.Mode == EFileExportMode::ConvertToExchangeFormat) + { + return LOCTEXT("FootnoteConvert", + "Conversion loads every asset. Blueprints and materials have no exchange format and are skipped."); + } + + return FText::GetEmpty(); + } + + FReply OnBrowse() + { + IDesktopPlatform* DesktopPlatform = FDesktopPlatformModule::Get(); + + if (!DesktopPlatform) + { + UE_LOG(LogAssetUsageAuditEditor, Error, TEXT("Export dialog: DesktopPlatform unavailable; cannot open a folder picker.")); + return FReply::Handled(); + } + + const void* ParentHandle = ParentWindow.IsValid() && ParentWindow->GetNativeWindow().IsValid() + ? ParentWindow->GetNativeWindow()->GetOSWindowHandle() + : nullptr; + + FString ChosenDir; + if (DesktopPlatform->OpenDirectoryDialog( + ParentHandle, + LOCTEXT("PickDir", "Choose a destination folder").ToString(), + Request.TargetDirectory, + ChosenDir)) + { + Request.TargetDirectory = ChosenDir; + } + + return FReply::Handled(); + } + + FReply OnAccept() + { + bAccepted = true; + CloseWindow(); + return FReply::Handled(); + } + + FReply OnCancel() + { + bAccepted = false; + CloseWindow(); + return FReply::Handled(); + } + + void CloseWindow() + { + if (ParentWindow.IsValid()) + { + ParentWindow->RequestDestroyWindow(); + } + } + + FAssetExportRequest Request; + int32 TickedCount = 0; + bool bAccepted = false; + + TSharedPtr ParentWindow; + TArray> LayoutOptions; +}; + +bool ShowAssetExportDialog(FAssetExportRequest& InOutRequest, int32 TickedCount) +{ + TSharedRef Window = SNew(SWindow) + .Title(LOCTEXT("WindowTitle", "Export ticked assets")) + .SizingRule(ESizingRule::Autosized) + .SupportsMinimize(false) + .SupportsMaximize(false); + + TSharedRef Dialog = SNew(SAssetExportDialog) + .InitialRequest(InOutRequest) + .TickedCount(TickedCount) + .ParentWindow(Window); + + Window->SetContent(Dialog); + + FSlateApplication::Get().AddModalWindow(Window, FGlobalTabmanager::Get()->GetRootWindow()); + + if (!Dialog->WasAccepted()) + { + UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Export cancelled in the options dialog.")); + return false; + } + + InOutRequest = Dialog->GetRequest(); + return true; +} + +#undef LOCTEXT_NAMESPACE diff --git a/Source/AssetUsageAuditEditor/Private/SAssetExportDialog.h b/Source/AssetUsageAuditEditor/Private/SAssetExportDialog.h new file mode 100644 index 0000000..34a85ad --- /dev/null +++ b/Source/AssetUsageAuditEditor/Private/SAssetExportDialog.h @@ -0,0 +1,75 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" +#include "AssetUsageAuditTypes.h" + +/** What "export the ticked files" does. */ +enum class EFileExportMode : uint8 +{ + /** Byte-exact copies of the package files. Loads nothing. */ + CopyPackages, + + /** Meshes to FBX, textures to PNG and so on, through the engine's exporters. */ + ConvertToExchangeFormat +}; + +/** + * Everything the export dialog collects. + * + * Lives here rather than in the panel so the two headers do not have to include each other: the + * panel needs the mode for its persisted state, and the dialog needs the whole request. + */ +struct FAssetExportRequest +{ + EFileExportMode Mode = EFileExportMode::CopyPackages; + + /** Absolute destination. Pre-filled from settings, editable in the dialog. */ + FString TargetDirectory; + + EExportLayout Layout = EExportLayout::Flat; + + /** Only meaningful with FolderPerAsset; the dialog disables it otherwise. */ + bool bGroupDependenciesByType = false; + + bool bIncludeDependencies = true; + + /** + * Write AssetUsageAudit.manifest.json beside the exported files. + * + * On by default: the Flat and FolderPerAsset layouts destroy the reference graph by design, and + * the manifest is the only record of what pointed at what. Meaningless under Migrate, where the + * engine keeps the paths intact. + */ + bool bWriteManifest = true; + + EExportCollisionPolicy CollisionPolicy = EExportCollisionPolicy::Index; +}; + +/** + * Show the modal export dialog. + * + * Modal on purpose. The alternative - a dockable options panel - lets the ticked set change while + * the options are open, so the figures on screen would describe a selection that no longer exists. + * + * @param InOutRequest Seeded with the current defaults; overwritten with the user's choices. + * @param TickedCount Shown in the dialog so the user can see what they are about to act on. + * @return true if the user pressed Export, false on Cancel or on closing the window. + */ +bool ShowAssetExportDialog(FAssetExportRequest& InOutRequest, int32 TickedCount); + +/** + * Why a folder cannot be used as a Migrate destination, or empty text when it can. + * + * Mirrors the two checks UAssetToolsImpl performs, because it performs them *after* being called + * and reports the failure only to the Output Log - so an export that cannot possibly work would + * otherwise look like a button that does nothing. + * + * The rules, from the engine: + * 1. the path must end in /Content/ (MigratePackages_ReportConfirmed); + * 2. the folder one level up must hold a .uproject, or exactly one .uplugin + * (FPackageMigrationImpl::GetMountPointRootPath) - that is how the destination's mount point + * is derived, and without it Migrate aborts. + */ +FText ValidateMigrateDestination(const FString& Directory); diff --git a/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.cpp b/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.cpp new file mode 100644 index 0000000..c74c8e9 --- /dev/null +++ b/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.cpp @@ -0,0 +1,2623 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "SAssetUsageAuditPanel.h" + +#include "AssetDependencyClosure.h" +#include "AssetTypeFilter.h" +#include "AssetExportLayout.h" +#include "AssetExportManifest.h" +#include "AssetUsageAuditEditorModule.h" +#include "AssetUsageAuditSettings.h" +#include "AssetUsageAuditUserSettings.h" +#include "AssetUsageExporter.h" +#include "AssetUsagePaths.h" +#include "AssetUsageReportWriter.h" +#include "AssetRegistry/AssetRegistryModule.h" +#include "AssetRegistry/IAssetRegistry.h" +#include "Algo/AllOf.h" +#include "AssetToolsModule.h" +#include "DesktopPlatformModule.h" +#include "IAssetTools.h" +#include "Framework/Application/SlateApplication.h" +#include "Framework/MultiBox/MultiBoxBuilder.h" +#include "IDesktopPlatform.h" +#include "Misc/MessageDialog.h" +#include "Misc/PackageName.h" +#include "Misc/Paths.h" +#include "Misc/ScopedSlowTask.h" +#include "Styling/AppStyle.h" +#include "Widgets/Input/SButton.h" +#include "Widgets/Input/SCheckBox.h" +#include "Widgets/Input/SComboButton.h" +#include "Widgets/Input/SSearchBox.h" +#include "Widgets/Input/SSegmentedControl.h" +#include "Widgets/Layout/SBorder.h" +#include "Widgets/Layout/SScrollBox.h" +#include "Widgets/Layout/SSeparator.h" +#include "Widgets/Layout/SWidgetSwitcher.h" +#include "Widgets/Notifications/SProgressBar.h" +#include "Widgets/Text/STextBlock.h" +#include "Widgets/Views/SExpanderArrow.h" + +#define LOCTEXT_NAMESPACE "SAssetUsageAuditPanel" + +namespace AuditColumns +{ + static const FName Check("Check"); + static const FName Type("Type"); + static const FName Name("Name"); + static const FName Path("Path"); + static const FName Verdict("Verdict"); + static const FName Levels("Levels"); + static const FName Hard("Hard"); + static const FName Soft("Soft"); + static const FName Provenance("Provenance"); + static const FName Route("Route"); +} + +namespace +{ + /** Colour-code the verdict so a 45k-row list is scannable rather than a wall of text. */ + FSlateColor VerdictColour(EAssetUsageVerdict Verdict) + { + switch (Verdict) + { + case EAssetUsageVerdict::UsedOnLevel: + return FSlateColor(FLinearColor(0.35f, 0.78f, 0.35f)); + case EAssetUsageVerdict::UsedByAssetsOnly: + return FSlateColor(FLinearColor(0.55f, 0.72f, 0.90f)); + case EAssetUsageVerdict::ReferencedFromConfigOrSource: + return FSlateColor(FLinearColor(0.85f, 0.75f, 0.35f)); + case EAssetUsageVerdict::Unreferenced: + return FSlateColor(FLinearColor(0.90f, 0.45f, 0.40f)); + case EAssetUsageVerdict::Unknown: + return FSlateColor(FLinearColor(0.65f, 0.65f, 0.65f)); + } + + return FSlateColor::UseForeground(); + } + + /** Short class label for the Type column: "/Script/Engine.StaticMesh" -> "StaticMesh". */ + FString ShortTypeName(const FTopLevelAssetPath& ClassPath) + { + if (ClassPath.IsNull()) + { + return FString(); + } + + return ClassPath.GetAssetName().ToString(); + } +} + +/** One row widget. Kept in this file: it has no use outside the panel. */ +class SAssetUsageRowWidget : public SMultiColumnTableRow +{ +public: + SLATE_BEGIN_ARGS(SAssetUsageRowWidget) {} + SLATE_ARGUMENT(FAssetUsageRowPtr, Item) + SLATE_EVENT(FOnCheckStateChanged, OnCheckChanged) + SLATE_ATTRIBUTE(ECheckBoxState, CheckState) + SLATE_END_ARGS() + + void Construct(const FArguments& InArgs, const TSharedRef& InOwnerTable) + { + Item = InArgs._Item; + OnCheckChanged = InArgs._OnCheckChanged; + CheckState = InArgs._CheckState; + + SMultiColumnTableRow::Construct(FSuperRowType::FArguments(), InOwnerTable); + } + + virtual TSharedRef GenerateWidgetForColumn(const FName& ColumnName) override + { + if (!Item.IsValid()) + { + return SNullWidget::NullWidget; + } + + const auto TextCell = [](const FString& Text, const FSlateColor& Colour = FSlateColor::UseForeground()) + { + return SNew(SBox) + .VAlign(VAlign_Center) + .Padding(FMargin(4.0f, 0.0f)) + [ + SNew(STextBlock) + .Text(FText::FromString(Text)) + .ColorAndOpacity(Colour) + .ToolTipText(FText::FromString(Text)) + ]; + }; + + if (ColumnName == AuditColumns::Check) + { + return SNew(SBox) + .HAlign(HAlign_Center) + .VAlign(VAlign_Center) + [ + SNew(SCheckBox) + .IsChecked(CheckState) + .OnCheckStateChanged(OnCheckChanged) + ]; + } + + if (ColumnName == AuditColumns::Type) + { + // Asset class, matching the type dropdown and PassesFilters exactly. Showing the + // gameplay class here instead would mean a row labelled "BP_Pickup_Child_C" is caught + // by the "Blueprint" filter - visibly inconsistent, and the sort of thing that makes + // someone distrust the whole table. + // + // The gameplay class is the more interesting fact for an artist, so it goes in the + // tooltip rather than being dropped. The report keeps it as its own column. + const FString Label = ShortTypeName(Item->ClassPath); + + FString Tooltip = Item->ClassPath.ToString(); + if (!Item->GeneratedClassPath.IsNull()) + { + Tooltip += FString::Printf(TEXT("\nGameplay class: %s"), *Item->GeneratedClassPath.ToString()); + } + + return SNew(SBox) + .VAlign(VAlign_Center) + .Padding(FMargin(4.0f, 0.0f)) + [ + SNew(STextBlock) + .Text(FText::FromString(Label)) + .ToolTipText(FText::FromString(Tooltip)) + ]; + } + + if (ColumnName == AuditColumns::Name) + { + return TextCell(Item->AssetName.ToString()); + } + + if (ColumnName == AuditColumns::Path) + { + return TextCell(Item->PathFromProjectRoot); + } + + if (ColumnName == AuditColumns::Verdict) + { + return TextCell(LexToString(Item->Verdict), VerdictColour(Item->Verdict)); + } + + if (ColumnName == AuditColumns::Levels) + { + // The count is what fits; the full list goes in the tooltip. + TArray LevelNames; + LevelNames.Reserve(Item->Levels.Num()); + for (const FName& Level : Item->Levels) + { + LevelNames.Add(Level.ToString()); + } + + const FText Tooltip = LevelNames.IsEmpty() + ? LOCTEXT("NoLevels", "Not reached by any level.") + : FText::FromString(FString::Join(LevelNames, TEXT("\n"))); + + return SNew(SBox) + .VAlign(VAlign_Center) + .HAlign(HAlign_Right) + .Padding(FMargin(4.0f, 0.0f)) + [ + SNew(STextBlock) + .Text(FText::AsNumber(Item->Levels.Num())) + .ToolTipText(Tooltip) + ]; + } + + if (ColumnName == AuditColumns::Hard) + { + return TextCell(FString::FromInt(Item->HardReferenceCount)); + } + + if (ColumnName == AuditColumns::Soft) + { + return TextCell(FString::FromInt(Item->SoftReferenceCount)); + } + + if (ColumnName == AuditColumns::Provenance) + { + return TextCell(ProvenanceToString(Item->Provenance)); + } + + if (ColumnName == AuditColumns::Route) + { + const FString RouteText = Item->Route.IsEmpty() ? Item->ProvenanceDetail : Item->Route; + return TextCell(RouteText); + } + + return SNullWidget::NullWidget; + } + +private: + FAssetUsageRowPtr Item; + FOnCheckStateChanged OnCheckChanged; + TAttribute CheckState; +}; + +void SAssetUsageAuditPanel::Construct(const FArguments& InArgs) +{ + // Everything visible until the user narrows it down. + VisibleVerdicts = TSet(GetAllAssetUsageVerdicts()); + + // Restore after the defaults are in place, so a config that resolves to nothing leaves the + // panel usable rather than empty. Must also happen before the widget tree is built: the + // segmented controls and checkboxes read these fields for their initial state. + LoadUserSettings(); + + StatusText = LOCTEXT("NotRunYet", "Press Run Audit to scan the project."); + + HeaderRow = SNew(SHeaderRow) + + + SHeaderRow::Column(AuditColumns::Check) + .DefaultLabel(FText::GetEmpty()) + .FixedWidth(28.0f) + .HAlignHeader(HAlign_Center) + [ + SNew(SCheckBox) + .IsChecked(this, &SAssetUsageAuditPanel::GetHeaderCheckState) + .OnCheckStateChanged(this, &SAssetUsageAuditPanel::OnHeaderCheckChanged) + .ToolTipText(LOCTEXT("CheckAllTip", "Tick or clear every row currently shown by the filters.")) + ] + + + SHeaderRow::Column(AuditColumns::Type) + .DefaultLabel(LOCTEXT("ColType", "Type")) + .FillWidth(0.12f) + .SortMode(this, &SAssetUsageAuditPanel::GetSortModeForColumn, AuditColumns::Type) + .OnSort(this, &SAssetUsageAuditPanel::OnSortChanged) + + + SHeaderRow::Column(AuditColumns::Name) + .DefaultLabel(LOCTEXT("ColName", "Name")) + .FillWidth(0.16f) + .SortMode(this, &SAssetUsageAuditPanel::GetSortModeForColumn, AuditColumns::Name) + .OnSort(this, &SAssetUsageAuditPanel::OnSortChanged) + + + SHeaderRow::Column(AuditColumns::Path) + .DefaultLabel(LOCTEXT("ColPath", "Path from project root")) + .FillWidth(0.26f) + .SortMode(this, &SAssetUsageAuditPanel::GetSortModeForColumn, AuditColumns::Path) + .OnSort(this, &SAssetUsageAuditPanel::OnSortChanged) + + + SHeaderRow::Column(AuditColumns::Verdict) + .DefaultLabel(LOCTEXT("ColVerdict", "Verdict")) + .FillWidth(0.13f) + .SortMode(this, &SAssetUsageAuditPanel::GetSortModeForColumn, AuditColumns::Verdict) + .OnSort(this, &SAssetUsageAuditPanel::OnSortChanged) + + + SHeaderRow::Column(AuditColumns::Levels) + .DefaultLabel(LOCTEXT("ColLevels", "Levels")) + .FixedWidth(60.0f) + .HAlignHeader(HAlign_Right) + .SortMode(this, &SAssetUsageAuditPanel::GetSortModeForColumn, AuditColumns::Levels) + .OnSort(this, &SAssetUsageAuditPanel::OnSortChanged) + + + SHeaderRow::Column(AuditColumns::Hard) + .DefaultLabel(LOCTEXT("ColHard", "Hard")) + .FixedWidth(50.0f) + .SortMode(this, &SAssetUsageAuditPanel::GetSortModeForColumn, AuditColumns::Hard) + .OnSort(this, &SAssetUsageAuditPanel::OnSortChanged) + + + SHeaderRow::Column(AuditColumns::Soft) + .DefaultLabel(LOCTEXT("ColSoft", "Soft")) + .FixedWidth(50.0f) + .SortMode(this, &SAssetUsageAuditPanel::GetSortModeForColumn, AuditColumns::Soft) + .OnSort(this, &SAssetUsageAuditPanel::OnSortChanged) + + + SHeaderRow::Column(AuditColumns::Provenance) + .DefaultLabel(LOCTEXT("ColProvenance", "Provenance")) + .FillWidth(0.13f) + + + SHeaderRow::Column(AuditColumns::Route) + .DefaultLabel(LOCTEXT("ColRoute", "Route")) + .FillWidth(0.20f); + + ChildSlot + [ + SNew(SVerticalBox) + + // --- Toolbar ------------------------------------------------------------------------- + + SVerticalBox::Slot() + .AutoHeight() + .Padding(4.0f) + [ + SNew(SHorizontalBox) + + + SHorizontalBox::Slot() + .AutoWidth() + .VAlign(VAlign_Center) + .Padding(2.0f, 0.0f) + [ + SNew(SButton) + .Text(LOCTEXT("RunAudit", "Run Audit")) + .ToolTipText(LOCTEXT("RunAuditTip", "Sweep the Asset Registry and work out which assets each level uses.")) + .OnClicked(this, &SAssetUsageAuditPanel::OnRunClicked) + .IsEnabled(this, &SAssetUsageAuditPanel::CanRun) + ] + + + SHorizontalBox::Slot() + .AutoWidth() + .VAlign(VAlign_Center) + .Padding(8.0f, 0.0f, 2.0f, 0.0f) + [ + SNew(SSegmentedControl) + .Value_Lambda([this]() { return ViewMode; }) + .OnValueChanged_Lambda([this](EAuditViewMode NewMode) + { + ViewMode = NewMode; + RebuildFilteredRows(); + }) + + SSegmentedControl::Slot(EAuditViewMode::ByAsset) + .Text(LOCTEXT("ByAsset", "By asset")) + .ToolTip(LOCTEXT("ByAssetTip", "List assets; the Levels column says where each one is used.")) + + SSegmentedControl::Slot(EAuditViewMode::ByLevel) + .Text(LOCTEXT("ByLevel", "By level")) + .ToolTip(LOCTEXT("ByLevelTip", "Group the results under the levels that use them. Leave the level scope on \"All levels\" to compare locations side by side.")) + ] + + + SHorizontalBox::Slot() + .AutoWidth() + .VAlign(VAlign_Center) + .Padding(8.0f, 0.0f, 2.0f, 0.0f) + [ + SNew(SComboButton) + .OnGetMenuContent(this, &SAssetUsageAuditPanel::MakeLevelFilterMenu) + .ButtonContent() + [ + SNew(STextBlock) + .Text_Lambda([this]() + { + if (LevelFilters.IsEmpty()) + { + return LOCTEXT("AllLevels", "All levels"); + } + + // Name the level when there is one, count them when there are several: + // a button reading "WP_Main" is worth more than one reading "1 level". + if (LevelFilters.Num() == 1) + { + return FText::FromString(FPackageName::GetShortName(*LevelFilters.CreateConstIterator())); + } + + return FText::Format(LOCTEXT("NLevels", "{0} levels"), FText::AsNumber(LevelFilters.Num())); + }) + ] + ] + + + SHorizontalBox::Slot() + .AutoWidth() + .VAlign(VAlign_Center) + .Padding(2.0f, 0.0f) + [ + SNew(SComboButton) + .OnGetMenuContent(this, &SAssetUsageAuditPanel::MakeTypeFilterMenu) + .ButtonContent() + [ + SNew(STextBlock) + .Text_Lambda([this]() + { + return VisibleTypeNames.IsEmpty() + ? LOCTEXT("AllTypes", "All types") + : FText::Format(LOCTEXT("NTypes", "{0} types"), FText::AsNumber(VisibleTypeNames.Num())); + }) + ] + ] + + + SHorizontalBox::Slot() + .AutoWidth() + .VAlign(VAlign_Center) + .Padding(2.0f, 0.0f) + [ + SNew(SComboButton) + .OnGetMenuContent(this, &SAssetUsageAuditPanel::MakeVerdictFilterMenu) + .ButtonContent() + [ + SNew(STextBlock) + .Text_Lambda([this]() + { + return FText::Format(LOCTEXT("NVerdicts", "Verdicts ({0}/5)"), FText::AsNumber(VisibleVerdicts.Num())); + }) + ] + ] + + + SHorizontalBox::Slot() + .AutoWidth() + .VAlign(VAlign_Center) + .Padding(2.0f, 0.0f) + [ + SNew(SComboButton) + .OnGetMenuContent(this, &SAssetUsageAuditPanel::MakeReferenceFilterMenu) + .ButtonContent() + [ + SNew(STextBlock) + // Label states the active constraint so a filtered view is never mistaken for + // the whole result - the mistake that makes someone delete a "missing" asset. + .Text_Lambda([this]() + { + const bool bHasProvenance = RequiredProvenance != EAssetUsageProvenance::None; + + if (ReferenceStrength == EReferenceStrengthFilter::Any && !bHasProvenance) + { + return LOCTEXT("RefsAny", "References"); + } + + FText StrengthLabel; + switch (ReferenceStrength) + { + case EReferenceStrengthFilter::HardOnly: StrengthLabel = LOCTEXT("RefsHard", "Hard only"); break; + case EReferenceStrengthFilter::SoftOnly: StrengthLabel = LOCTEXT("RefsSoft", "Soft only"); break; + case EReferenceStrengthFilter::Mixed: StrengthLabel = LOCTEXT("RefsMixed", "Mixed"); break; + case EReferenceStrengthFilter::None: StrengthLabel = LOCTEXT("RefsNone", "No edges"); break; + default: StrengthLabel = LOCTEXT("RefsAnyShort", "Any"); break; + } + + if (!bHasProvenance) + { + return FText::Format(LOCTEXT("RefsStrengthOnly", "Refs: {0}"), StrengthLabel); + } + + return FText::Format( + LOCTEXT("RefsStrengthAndProv", "Refs: {0} +{1}"), + StrengthLabel, + FText::FromString(ProvenanceToString(RequiredProvenance))); + }) + ] + ] + + // Gameplay class is a text box, not a dropdown: there are thousands of them on this + // project, which is exactly why they were taken out of the type menu. + + SHorizontalBox::Slot() + .FillWidth(0.5f) + .VAlign(VAlign_Center) + .Padding(8.0f, 0.0f, 0.0f, 0.0f) + [ + SNew(SSearchBox) + .HintText(LOCTEXT("ClassHint", "Gameplay class, e.g. Pickup")) + .ToolTipText(LOCTEXT("ClassTip", "Matches the Blueprint's generated class. Rows that are not Blueprints are hidden while this is set.")) + .OnTextChanged_Lambda([this](const FText& NewText) + { + GeneratedClassFilter = NewText.ToString(); + RebuildFilteredRows(); + }) + ] + + + SHorizontalBox::Slot() + .FillWidth(1.0f) + .VAlign(VAlign_Center) + .Padding(8.0f, 0.0f) + [ + SNew(SSearchBox) + .HintText(LOCTEXT("SearchHint", "Filter by name or path")) + .OnTextChanged(this, &SAssetUsageAuditPanel::OnSearchTextChanged) + ] + ] + + + SVerticalBox::Slot() + .AutoHeight() + [ + SNew(SSeparator) + ] + + // --- List ---------------------------------------------------------------------------- + + SVerticalBox::Slot() + .FillHeight(1.0f) + [ + SAssignNew(TreeView, STreeView) + .TreeItemsSource(&RootItems) + .OnGenerateRow(this, &SAssetUsageAuditPanel::OnGenerateRow) + .OnGetChildren(this, &SAssetUsageAuditPanel::OnGetChildren) + .OnMouseButtonDoubleClick(this, &SAssetUsageAuditPanel::OnItemDoubleClicked) + .SelectionMode(ESelectionMode::Multi) + .HeaderRow(HeaderRow) + ] + + // --- Status -------------------------------------------------------------------------- + + SVerticalBox::Slot() + .AutoHeight() + .Padding(4.0f) + [ + SNew(STextBlock) + .Text_Lambda([this]() { return StatusText; }) + ] + + // --- Export bar ---------------------------------------------------------------------- + // + // Below the results, not above them. The top strip is for narrowing down what you are + // looking at; exporting acts on what you have already found, and reads left to right as + // "N ticked -> export". Keeping it at the top put an action button between two filters. + + SVerticalBox::Slot() + .AutoHeight() + [ + SNew(SSeparator) + ] + + + SVerticalBox::Slot() + .AutoHeight() + .Padding(4.0f, 4.0f, 4.0f, 4.0f) + [ + SNew(SHorizontalBox) + + + SHorizontalBox::Slot() + .AutoWidth() + .VAlign(VAlign_Center) + .Padding(2.0f, 0.0f) + [ + SNew(STextBlock) + .Text(this, &SAssetUsageAuditPanel::GetSelectionSummaryText) + ] + + + SHorizontalBox::Slot() + .FillWidth(1.0f) + [ + SNullWidget::NullWidget + ] + + + SHorizontalBox::Slot() + .AutoWidth() + .VAlign(VAlign_Center) + .Padding(2.0f, 0.0f) + [ + SNew(SButton) + .Text(LOCTEXT("ExportReport", "Export report (JSON + CSV)")) + .ToolTipText(LOCTEXT("ExportReportTip", "Write the current, filtered result as a JSON and a CSV file.")) + .OnClicked(this, &SAssetUsageAuditPanel::OnExportReportClicked) + .IsEnabled_Lambda([this]() { return bHasRun; }) + ] + + // Mode, layout, destination and the dependency switch all moved into the export window. + // They were four controls competing with the filters for the same strip of toolbar, and + // they are only ever touched at the moment of exporting - which is exactly when a window + // can show what the choices will produce, instead of leaving the user to guess. + + SHorizontalBox::Slot() + .AutoWidth() + .VAlign(VAlign_Center) + .Padding(2.0f, 0.0f) + [ + SNew(SButton) + .Text(LOCTEXT("ExportFilesEllipsis", "Export ticked assets...")) + .ToolTipText(LOCTEXT("ExportFilesTip", "Open the export window: choose copy or convert, the folder layout and the destination.")) + .OnClicked(this, &SAssetUsageAuditPanel::OnExportFilesClicked) + .IsEnabled(this, &SAssetUsageAuditPanel::HasCheckedAssets) + ] + ] + ]; +} + +// --- Running --------------------------------------------------------------------------------- + +bool SAssetUsageAuditPanel::CanRun() const +{ + return true; +} + +FReply SAssetUsageAuditPanel::OnRunClicked() +{ + RunAudit(); + return FReply::Handled(); +} + +void SAssetUsageAuditPanel::RunAudit() +{ + FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")); + IAssetRegistry& AssetRegistry = AssetRegistryModule.Get(); + + if (AssetRegistry.IsLoadingAssets()) + { + StatusText = LOCTEXT("WaitingForRegistry", "Asset Registry is still scanning; waiting for it to finish..."); + AssetRegistry.WaitForCompletion(); + } + + const UAssetUsageAuditSettings* Settings = UAssetUsageAuditSettings::Get(); + + FAssetUsageAuditRequest Request; + if (Settings) + { + Request.IncludePackagePaths = Settings->IncludedPackagePaths; + Request.ExcludePackagePaths = Settings->ExcludedPackagePaths; + Request.bScanIndirectReferences = Settings->bScanIndirectReferences; + Request.bHideExternalPackages = Settings->bHideExternalPackages; + } + + // Scope the sweep to the chosen levels. Previously this was never set, so the panel always + // swept all 1072 levels and the level dropdown could only filter the result afterwards - + // which is not the question the task asks ("assets of type X for location Y"). + // + // An empty set means every level, which is what the resolver already does with an empty array. + for (FName Level : LevelFilters) + { + Request.LevelPackages.Add(Level); + } + + // Sorted so two runs over the same selection produce the same report ordering, and so the + // header line in the log is comparable between runs. + Request.LevelPackages.Sort(FNameLexicalLess()); + + FScopedSlowTask SlowTask(100.0f, LOCTEXT("Auditing", "Auditing asset usage...")); + SlowTask.MakeDialog(true); + + Request.ResolveOptions.OnLevelProgress = [&SlowTask](int32 Current, int32 Total) + { + if (Total > 0 && Current % 16 == 0) + { + SlowTask.EnterProgressFrame(16.0f * 100.0f / static_cast(Total)); + } + }; + + Request.ResolveOptions.ShouldAbort = [&SlowTask]() + { + return SlowTask.ShouldCancel(); + }; + + UE_LOG(LogAssetUsageAuditEditor, Log, + TEXT("Panel: starting audit. Include=[%s] Exclude=[%s] IndirectScan=%s HideExternal=%s"), + Request.IncludePackagePaths.IsEmpty() ? TEXT("/Game") : *FString::Join(Request.IncludePackagePaths, TEXT(", ")), + Request.ExcludePackagePaths.IsEmpty() ? TEXT("none") : *FString::Join(Request.ExcludePackagePaths, TEXT(", ")), + Request.bScanIndirectReferences ? TEXT("on") : TEXT("off"), + Request.bHideExternalPackages ? TEXT("on") : TEXT("off")); + + LastResult = FAssetUsageAuditor::Run(AssetRegistry, Request); + + // Logged as well as shown on screen: when someone reports "the numbers look wrong", the status + // line is already gone and the settings that produced them are not recoverable from a + // screenshot. The pair above and below is what makes a report reproducible after the fact. + UE_LOG(LogAssetUsageAuditEditor, Log, + TEXT("Panel: audit finished in %.2fs. %d rows - OnLevel %d, AssetsOnly %d, Config/Source %d, Unreferenced %d, Unknown %d."), + LastResult.Header.ScanDurationSeconds, + LastResult.Rows.Num(), + LastResult.CountByVerdict(EAssetUsageVerdict::UsedOnLevel), + LastResult.CountByVerdict(EAssetUsageVerdict::UsedByAssetsOnly), + LastResult.CountByVerdict(EAssetUsageVerdict::ReferencedFromConfigOrSource), + LastResult.CountByVerdict(EAssetUsageVerdict::Unreferenced), + LastResult.CountByVerdict(EAssetUsageVerdict::Unknown)); + + AllRows.Reset(LastResult.Rows.Num()); + for (const FAssetUsageRow& Row : LastResult.Rows) + { + AllRows.Add(MakeShared(Row)); + } + + bHasRun = true; + + // Ticks are keyed by package name, so they survive a re-run. Drop only the ones whose asset + // no longer exists, rather than silently clearing the user's whole selection. + if (!CheckedPackages.IsEmpty()) + { + TSet StillPresent; + for (const FAssetUsageRowPtr& Row : AllRows) + { + if (CheckedPackages.Contains(Row->PackageName)) + { + StillPresent.Add(Row->PackageName); + } + } + + CheckedPackages = MoveTemp(StillPresent); + } + + RebuildFilteredRows(); + + StatusText = FText::Format( + LOCTEXT("AuditDone", "{0} rows in {1} s. On level: {2} | Assets only: {3} | Config/Source: {4} | Unreferenced: {5} | Unknown: {6}"), + FText::AsNumber(LastResult.Rows.Num()), + FText::AsNumber(FMath::RoundToFloat(LastResult.Header.ScanDurationSeconds * 100.0f) / 100.0f), + FText::AsNumber(LastResult.CountByVerdict(EAssetUsageVerdict::UsedOnLevel)), + FText::AsNumber(LastResult.CountByVerdict(EAssetUsageVerdict::UsedByAssetsOnly)), + FText::AsNumber(LastResult.CountByVerdict(EAssetUsageVerdict::ReferencedFromConfigOrSource)), + FText::AsNumber(LastResult.CountByVerdict(EAssetUsageVerdict::Unreferenced)), + FText::AsNumber(LastResult.CountByVerdict(EAssetUsageVerdict::Unknown))); +} + +/** + * A level heading in by-level mode. + * + * Deliberately not the asset row widget with blank cells: a heading answers a different question + * ("this location, this many assets") and filling the asset columns with empty space made the two + * kinds of line hard to tell apart at a glance. + */ +class SAuditLevelRowWidget : public SMultiColumnTableRow +{ +public: + SLATE_BEGIN_ARGS(SAuditLevelRowWidget) {} + SLATE_ARGUMENT(FAuditTreeItemPtr, Item) + SLATE_EVENT(FOnCheckStateChanged, OnCheckChanged) + SLATE_ATTRIBUTE(ECheckBoxState, CheckState) + SLATE_END_ARGS() + + void Construct(const FArguments& InArgs, const TSharedRef& InOwnerTable) + { + Item = InArgs._Item; + OnCheckChanged = InArgs._OnCheckChanged; + CheckState = InArgs._CheckState; + + SMultiColumnTableRow::Construct(FSuperRowType::FArguments(), InOwnerTable); + } + + virtual TSharedRef GenerateWidgetForColumn(const FName& ColumnName) override + { + if (!Item.IsValid()) + { + return SNullWidget::NullWidget; + } + + if (ColumnName == AuditColumns::Check) + { + return SNew(SBox) + .VAlign(VAlign_Center) + .HAlign(HAlign_Center) + [ + SNew(SCheckBox) + .IsChecked(CheckState) + .OnCheckStateChanged(OnCheckChanged) + .ToolTipText(LOCTEXT("LevelCheckTip", "Tick every asset shown under this level.")) + ]; + } + + if (ColumnName == AuditColumns::Name) + { + const FString LevelPath = Item->LevelPackage.ToString(); + + return SNew(SHorizontalBox) + + + SHorizontalBox::Slot() + .AutoWidth() + .VAlign(VAlign_Center) + [ + SNew(SExpanderArrow, SharedThis(this)) + ] + + + SHorizontalBox::Slot() + .FillWidth(1.0f) + .VAlign(VAlign_Center) + [ + SNew(STextBlock) + .Text(FText::FromString(FPackageName::GetShortName(LevelPath))) + .ToolTipText(FText::FromString(LevelPath)) + .Font(FAppStyle::GetFontStyle("BoldFont")) + ]; + } + + if (ColumnName == AuditColumns::Path) + { + return SNew(SBox) + .VAlign(VAlign_Center) + .Padding(FMargin(4.0f, 0.0f)) + [ + SNew(STextBlock) + .Text(FText::Format( + LOCTEXT("LevelAssetCount", "{0} asset(s)"), + FText::AsNumber(Item->Children.Num()))) + .ColorAndOpacity(FSlateColor::UseSubduedForeground()) + ]; + } + + return SNullWidget::NullWidget; + } + +private: + FAuditTreeItemPtr Item; + FOnCheckStateChanged OnCheckChanged; + TAttribute CheckState; +}; + +// --- Tree ------------------------------------------------------------------------------------ + +TSharedRef SAssetUsageAuditPanel::OnGenerateRow(FAuditTreeItemPtr Item, const TSharedRef& OwnerTable) +{ + if (Item.IsValid() && Item->IsLevel()) + { + return SNew(SAuditLevelRowWidget, OwnerTable) + .Item(Item) + .CheckState(TAttribute::CreateLambda([this, Item]() { return GetLevelCheckState(Item); })) + .OnCheckChanged(FOnCheckStateChanged::CreateLambda([this, Item](ECheckBoxState NewState) + { + OnLevelCheckChanged(NewState, Item); + })); + } + + const FAssetUsageRowPtr Row = Item.IsValid() ? Item->Row : nullptr; + + return SNew(SAssetUsageRowWidget, OwnerTable) + .Item(Row) + .CheckState(TAttribute::CreateLambda([this, Row]() { return GetRowCheckState(Row); })) + .OnCheckChanged(FOnCheckStateChanged::CreateLambda([this, Row](ECheckBoxState NewState) + { + OnRowCheckChanged(NewState, Row); + })); +} + +void SAssetUsageAuditPanel::OnGetChildren(FAuditTreeItemPtr Item, TArray& OutChildren) +{ + if (Item.IsValid()) + { + OutChildren = Item->Children; + } +} + +void SAssetUsageAuditPanel::OnItemDoubleClicked(FAuditTreeItemPtr Item) +{ + if (!Item.IsValid()) + { + return; + } + + const FName PackageName = Item->IsLevel() + ? Item->LevelPackage + : (Item->Row.IsValid() ? Item->Row->PackageName : NAME_None); + + if (PackageName.IsNone()) + { + return; + } + + if (!GEditor) + { + UE_LOG(LogAssetUsageAuditEditor, Warning, TEXT("Cannot reveal '%s': no editor engine."), *PackageName.ToString()); + return; + } + + IAssetRegistry& Registry = FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")).Get(); + + TArray Assets; + Registry.GetAssetsByPackageName(PackageName, Assets, /*bIncludeOnlyOnDiskAssets=*/true); + + // A package can hold several assets. Sync to the one the row is actually about; falling back to + // the whole package would reveal a redirector or a secondary object and look like the wrong row + // was selected. + if (!Item->IsLevel() && Item->Row.IsValid()) + { + const FName WantedAsset = Item->Row->AssetName; + + Assets.RemoveAll([WantedAsset](const FAssetData& Candidate) + { + return !WantedAsset.IsNone() && Candidate.AssetName != WantedAsset; + }); + } + + if (Assets.IsEmpty()) + { + // Ordinary for a package the registry knows only as a dependency target, and for anything + // that has no file on disk. Say so rather than silently doing nothing on a double-click. + StatusText = FText::Format( + LOCTEXT("RevealMissing", "'{0}' has no asset in the Content Browser - it may have been deleted or never saved."), + FText::FromName(PackageName)); + + UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Browse To: no on-disk asset for package '%s'."), *PackageName.ToString()); + return; + } + + GEditor->SyncBrowserToObjects(Assets); +} + +ECheckBoxState SAssetUsageAuditPanel::GetLevelCheckState(FAuditTreeItemPtr Item) const +{ + if (!Item.IsValid() || Item->Children.IsEmpty()) + { + return ECheckBoxState::Unchecked; + } + + int32 Ticked = 0; + + for (const FAuditTreeItemPtr& Child : Item->Children) + { + if (Child.IsValid() && Child->Row.IsValid() && CheckedPackages.Contains(Child->Row->PackageName)) + { + ++Ticked; + } + } + + if (Ticked == 0) + { + return ECheckBoxState::Unchecked; + } + + // Undetermined rather than Checked for a partial selection: showing a full tick over a level + // where half the assets are ticked would misreport what the export is about to do. + return Ticked == Item->Children.Num() ? ECheckBoxState::Checked : ECheckBoxState::Undetermined; +} + +void SAssetUsageAuditPanel::OnLevelCheckChanged(ECheckBoxState NewState, FAuditTreeItemPtr Item) +{ + if (!Item.IsValid()) + { + return; + } + + const bool bTick = (NewState == ECheckBoxState::Checked); + + for (const FAuditTreeItemPtr& Child : Item->Children) + { + if (!Child.IsValid() || !Child->Row.IsValid()) + { + continue; + } + + if (bTick) + { + CheckedPackages.Add(Child->Row->PackageName); + } + else + { + CheckedPackages.Remove(Child->Row->PackageName); + } + } +} + +void SAssetUsageAuditPanel::RebuildTree() +{ + RootItems.Reset(); + + if (ViewMode == EAuditViewMode::ByAsset) + { + RootItems.Reserve(FilteredRows.Num()); + + for (const FAssetUsageRowPtr& Row : FilteredRows) + { + FAuditTreeItemPtr Node = MakeShared(); + Node->Row = Row; + RootItems.Add(MoveTemp(Node)); + } + + return; + } + + // By level. Group in one pass, preserving the sorted order of FilteredRows inside each level so + // the column sort still means something after grouping. + TMap LevelNodes; + + for (const FAssetUsageRowPtr& Row : FilteredRows) + { + if (!Row.IsValid()) + { + continue; + } + + for (FName LevelPackage : Row->Levels) + { + // A chosen scope narrows the tree to those levels; without a scope every level that uses + // the asset gets a heading, which is what makes comparing locations possible. + if (!LevelFilters.IsEmpty() && !LevelFilters.Contains(LevelPackage)) + { + continue; + } + + FAuditTreeItemPtr& LevelNode = LevelNodes.FindOrAdd(LevelPackage); + if (!LevelNode.IsValid()) + { + LevelNode = MakeShared(); + LevelNode->LevelPackage = LevelPackage; + } + + FAuditTreeItemPtr Child = MakeShared(); + Child->Row = Row; + LevelNode->Children.Add(MoveTemp(Child)); + } + } + + LevelNodes.GenerateValueArray(RootItems); + + // Biggest first: on a full sweep this is 1072 headings, and the levels worth looking at are the + // ones with content. Alphabetical would bury them. + RootItems.Sort([](const FAuditTreeItemPtr& A, const FAuditTreeItemPtr& B) + { + if (A->Children.Num() != B->Children.Num()) + { + return A->Children.Num() > B->Children.Num(); + } + return A->LevelPackage.LexicalLess(B->LevelPackage); + }); + + // Expand automatically only when there is exactly one level to look at. Expanding 1072 headings + // would render tens of thousands of rows and defeat the point of grouping them. + if (RootItems.Num() == 1 && TreeView.IsValid()) + { + TreeView->SetItemExpansion(RootItems[0], true); + } +} + +void SAssetUsageAuditPanel::RebuildFilteredRows() +{ + FilteredRows.Reset(); + + for (const FAssetUsageRowPtr& Row : AllRows) + { + if (Row.IsValid() && PassesFilters(*Row)) + { + FilteredRows.Add(Row); + } + } + + ApplySorting(); + + RebuildTree(); + + if (TreeView.IsValid()) + { + TreeView->RequestTreeRefresh(); + } +} + +// --- Filtering ------------------------------------------------------------------------------- + +bool SAssetUsageAuditPanel::PassesFilters(const FAssetUsageRow& Row) const +{ + if (!VisibleVerdicts.Contains(Row.Verdict)) + { + return false; + } + + // Asset class only, matching what the dropdown offers. A Blueprint reads as "Blueprint" here; + // asking "which of these is a StaticMeshActor" is the generated-class filter below, because + // mixing the two axes is what made the type menu unusable. + if (!VisibleTypeNames.IsEmpty()) + { + if (!VisibleTypeNames.Contains(FName(*ShortTypeName(Row.ClassPath)))) + { + return false; + } + } + + // Substring rather than an exact list: gameplay classes number in the thousands, so this is a + // text box, and "Pickup" usefully matches BP_Pickup_Child_C and BP_Pickup_Master alike. + if (!GeneratedClassFilter.IsEmpty()) + { + if (Row.GeneratedClassPath.IsNull()) + { + return false; + } + + if (!ShortTypeName(Row.GeneratedClassPath).Contains(GeneratedClassFilter, ESearchCase::IgnoreCase)) + { + return false; + } + } + + if (ReferenceStrength != EReferenceStrengthFilter::Any) + { + const bool bHasHard = Row.HardReferenceCount > 0; + const bool bHasSoft = Row.SoftReferenceCount > 0; + + bool bStrengthMatches = false; + switch (ReferenceStrength) + { + case EReferenceStrengthFilter::HardOnly: + bStrengthMatches = bHasHard && !bHasSoft; + break; + case EReferenceStrengthFilter::SoftOnly: + bStrengthMatches = bHasSoft && !bHasHard; + break; + case EReferenceStrengthFilter::Mixed: + bStrengthMatches = bHasHard && bHasSoft; + break; + case EReferenceStrengthFilter::None: + bStrengthMatches = !bHasHard && !bHasSoft; + break; + default: + bStrengthMatches = true; + break; + } + + if (!bStrengthMatches) + { + return false; + } + } + + // ANDed, not ORed: ticking two flags asks for rows carrying both, which is what makes + // combinations like "editor-only AND through an external actor" expressible. + if (RequiredProvenance != EAssetUsageProvenance::None) + { + if (!EnumHasAllFlags(Row.Provenance, RequiredProvenance)) + { + return false; + } + } + + // Any of the chosen levels is enough. Requiring all of them would answer "what do these + // locations share", which is a different and much rarer question than "what is on these". + if (!LevelFilters.IsEmpty()) + { + bool bOnAnyChosenLevel = false; + for (FName Level : Row.Levels) + { + if (LevelFilters.Contains(Level)) + { + bOnAnyChosenLevel = true; + break; + } + } + + if (!bOnAnyChosenLevel) + { + return false; + } + } + + // By-level groups rows under level headings, so a row belonging to no level has nowhere to sit. + // Before the tree existed this mode showed nothing at all until a level was picked; grouping + // makes the unpicked case the useful one - every level at once, which is what comparing two + // locations requires. + if (ViewMode == EAuditViewMode::ByLevel && Row.Levels.IsEmpty()) + { + return false; + } + + if (!SearchText.IsEmpty()) + { + const bool bMatches = + Row.AssetName.ToString().Contains(SearchText, ESearchCase::IgnoreCase) || + Row.PathFromProjectRoot.Contains(SearchText, ESearchCase::IgnoreCase); + + if (!bMatches) + { + return false; + } + } + + return true; +} + +void SAssetUsageAuditPanel::OnSearchTextChanged(const FText& NewText) +{ + SearchText = NewText.ToString(); + RebuildFilteredRows(); +} + +TSharedRef SAssetUsageAuditPanel::MakeVerdictFilterMenu() +{ + FMenuBuilder MenuBuilder(false, nullptr); + + const TArray AllVerdicts = GetAllAssetUsageVerdicts(); + + for (EAssetUsageVerdict Verdict : AllVerdicts) + { + MenuBuilder.AddMenuEntry( + FText::FromString(LexToString(Verdict)), + Verdict == EAssetUsageVerdict::Unknown + ? LOCTEXT("UnknownTip", "The registry cannot see this asset's references (FMOD, DataTable rows, runtime-built paths). Unknown does NOT mean unused.") + : FText::GetEmpty(), + FSlateIcon(), + FUIAction( + FExecuteAction::CreateLambda([this, Verdict]() + { + if (VisibleVerdicts.Contains(Verdict)) + { + VisibleVerdicts.Remove(Verdict); + } + else + { + VisibleVerdicts.Add(Verdict); + } + RebuildFilteredRows(); + }), + FCanExecuteAction(), + FIsActionChecked::CreateLambda([this, Verdict]() { return VisibleVerdicts.Contains(Verdict); })), + NAME_None, + EUserInterfaceActionType::ToggleButton); + } + + return MenuBuilder.MakeWidget(); +} + +void SAssetUsageAuditPanel::RefreshAvailableFilters() +{ + AvailableLevels.Reset(); + AvailableTypes.Reset(); + bAvailableFiltersReady = false; + + FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")); + IAssetRegistry& AssetRegistry = AssetRegistryModule.Get(); + + if (AssetRegistry.IsLoadingAssets()) + { + // Never block the UI thread on a full project scan just to populate a dropdown. The menu + // shows an explanatory entry instead, and the next open retries. + UE_LOG(LogAssetUsageAuditEditor, Log, + TEXT("Filter lists not built yet: the Asset Registry is still scanning.")); + return; + } + + const UAssetUsageAuditSettings* Settings = UAssetUsageAuditSettings::Get(); + const TArray ExcludedPaths = Settings ? Settings->ExcludedPackagePaths : TArray(); + + FARFilter Filter; + Filter.bRecursivePaths = true; + Filter.bIncludeOnlyOnDiskAssets = true; + + if (Settings && !Settings->IncludedPackagePaths.IsEmpty()) + { + for (const FString& Path : Settings->IncludedPackagePaths) + { + Filter.PackagePaths.Add(FName(*Path)); + } + } + else + { + Filter.PackagePaths.Add(FName(TEXT("/Game"))); + } + + FARCompiledFilter CompiledFilter; + AssetRegistry.CompileFilter(Filter, CompiledFilter); + + const FTopLevelAssetPath WorldClassPath = UWorld::StaticClass()->GetClassPathName(); + static const FName GeneratedClassTag(TEXT("GeneratedClass")); + + TSet TypeSet; + const double StartTime = FPlatformTime::Seconds(); + + AssetRegistry.EnumerateAssets(CompiledFilter, [&](const FAssetData& AssetData) + { + const FName PackageName = AssetData.PackageName; + + if (AssetUsagePaths::IsPathExcluded(PackageName, ExcludedPaths)) + { + return true; + } + + // OFPA packages are machine-named hashes. Offering 16117 of them in a dropdown would bury + // the levels a person is actually looking for. + if (AssetUsagePaths::IsExternalPackage(PackageName)) + { + return true; + } + + if (AssetData.AssetClassPath == WorldClassPath) + { + AvailableLevels.Add(PackageName); + } + + // Asset class only - deliberately NOT the Blueprint's generated class. + // + // Folding generated classes in here produced 2980 entries on this project, because every + // Blueprint contributes its own. A dropdown that long is no more usable than an empty one: + // someone looking for "StaticMesh" has to scroll past BP_Pickup_Child_C and its thousand + // siblings. Asset class alone is a bounded list of real asset types. + // + // Filtering by gameplay class is a different question and gets its own text box; the type + // presets in FAssetTypeFilter still expand through GetDerivedClassNames + the GeneratedClass + // tag, so nothing is lost from the analysis - only from this menu. + const FString AssetTypeName = ShortTypeName(AssetData.AssetClassPath); + if (!AssetTypeName.IsEmpty()) + { + TypeSet.Add(FName(*AssetTypeName)); + } + + return true; + }, UE::AssetRegistry::EEnumerateAssetsFlags::OnlyOnDiskAssets); + + AvailableLevels.Sort([](const FName& A, const FName& B) { return A.LexicalLess(B); }); + + AvailableTypes = TypeSet.Array(); + AvailableTypes.Sort([](const FName& A, const FName& B) { return A.LexicalLess(B); }); + + bAvailableFiltersReady = true; + + UE_LOG(LogAssetUsageAuditEditor, Log, + TEXT("Filter lists built in %.2fs: %d levels, %d asset types."), + FPlatformTime::Seconds() - StartTime, AvailableLevels.Num(), AvailableTypes.Num()); +} + +SAssetUsageAuditPanel::~SAssetUsageAuditPanel() +{ + SaveUserSettings(); +} + +void SAssetUsageAuditPanel::LoadUserSettings() +{ + const UAssetUsageAuditUserSettings* Saved = UAssetUsageAuditUserSettings::Get(); + if (!Saved) + { + return; + } + + for (const FString& LevelPackage : Saved->LastLevelPackages) + { + if (!LevelPackage.IsEmpty()) + { + LevelFilters.Add(FName(*LevelPackage)); + } + } + GeneratedClassFilter = Saved->GeneratedClassFilter; + bIncludeDependencies = Saved->bIncludeDependencies; + + for (const FString& TypeName : Saved->VisibleTypeNames) + { + VisibleTypeNames.Add(FName(*TypeName)); + } + + // Verdicts are stored by name, so an enum that gains a value does not silently reinterpret an + // old index. An unrecognised name is dropped rather than guessed at. + if (Saved->VisibleVerdictNames.Num() > 0) + { + TSet Restored; + + for (const FString& VerdictName : Saved->VisibleVerdictNames) + { + for (EAssetUsageVerdict Verdict : GetAllAssetUsageVerdicts()) + { + if (VerdictName.Equals(LexToString(Verdict), ESearchCase::IgnoreCase)) + { + Restored.Add(Verdict); + break; + } + } + } + + // An empty restored set would hide every row with no way to tell why, so the default + // "everything visible" wins over a config file that resolved to nothing. + if (Restored.Num() > 0) + { + VisibleVerdicts = MoveTemp(Restored); + } + } + + // Integers are validated rather than cast: an out-of-range value in a hand-edited ini would + // otherwise become an invalid enum and take a branch nobody wrote. + if (Saved->ViewMode >= 0 && Saved->ViewMode <= static_cast(EAuditViewMode::ByLevel)) + { + ViewMode = static_cast(Saved->ViewMode); + } + + if (Saved->ExportMode >= 0 && Saved->ExportMode <= static_cast(EFileExportMode::ConvertToExchangeFormat)) + { + ExportMode = static_cast(Saved->ExportMode); + } + + if (Saved->ReferenceStrength >= 0 && Saved->ReferenceStrength <= static_cast(EReferenceStrengthFilter::None)) + { + ReferenceStrength = static_cast(Saved->ReferenceStrength); + } + + RequiredProvenance = static_cast( + static_cast(Saved->RequiredProvenance & 0xFF)); +} + +void SAssetUsageAuditPanel::SaveUserSettings() const +{ + UAssetUsageAuditUserSettings* Saved = UAssetUsageAuditUserSettings::Get(); + if (!Saved) + { + return; + } + + Saved->LastLevelPackages.Reset(); + for (FName Level : LevelFilters) + { + Saved->LastLevelPackages.Add(Level.ToString()); + } + Saved->GeneratedClassFilter = GeneratedClassFilter; + Saved->bIncludeDependencies = bIncludeDependencies; + + Saved->VisibleTypeNames.Reset(); + for (FName TypeName : VisibleTypeNames) + { + Saved->VisibleTypeNames.Add(TypeName.ToString()); + } + + Saved->VisibleVerdictNames.Reset(); + for (EAssetUsageVerdict Verdict : VisibleVerdicts) + { + Saved->VisibleVerdictNames.Add(LexToString(Verdict)); + } + + Saved->ViewMode = static_cast(ViewMode); + Saved->ExportMode = static_cast(ExportMode); + Saved->ReferenceStrength = static_cast(ReferenceStrength); + Saved->RequiredProvenance = static_cast(RequiredProvenance); + + Saved->SaveConfig(); +} + +TSet SAssetUsageAuditPanel::ResolvePresetTypeNames(EAssetTypePreset Preset) const +{ + TSet Out; + + IAssetRegistry* Registry = IAssetRegistry::Get(); + if (!Registry) + { + return Out; + } + + const TArray Seeds = GetPresetClassPaths(Preset); + if (Seeds.IsEmpty()) + { + return Out; + } + + TSet Derived; + Registry->GetDerivedClassNames(Seeds, TSet(), Derived); + + // GetDerivedClassNames returns strict descendants in some builds and includes the seeds in + // others. Adding the seeds explicitly makes the result the same either way - the same reason + // FAssetTypeFilter::Compile does it. + for (const FTopLevelAssetPath& ClassPath : Seeds) + { + Out.Add(ClassPath.GetAssetName()); + } + + for (const FTopLevelAssetPath& ClassPath : Derived) + { + Out.Add(ClassPath.GetAssetName()); + } + + return Out; +} + +TSharedRef SAssetUsageAuditPanel::MakeTypeFilterMenu() +{ + if (!bAvailableFiltersReady) + { + RefreshAvailableFilters(); + } + + // Counts come from the last result when there is one, so the menu reads as + // "StaticMesh (1204)" after a run and simply "StaticMesh" before it. + // Keyed on asset class, the same axis the menu lists and PassesFilters matches. Keying these + // on the generated class instead would silently show "Blueprint (0)" next to thousands of + // Blueprint rows, because the counts and the entries would be on different axes. + TMap TypeCounts; + for (const FAssetUsageRowPtr& Row : AllRows) + { + const FName TypeName(*ShortTypeName(Row->ClassPath)); + if (!TypeName.IsNone()) + { + ++TypeCounts.FindOrAdd(TypeName); + } + } + + // Offer every type in the project, not only the ones the last sweep happened to return. + TArray> Sorted; + Sorted.Reserve(AvailableTypes.Num()); + for (const FName& TypeName : AvailableTypes) + { + Sorted.Emplace(TypeName, TypeCounts.FindRef(TypeName)); + } + + // Types present in the result first, by count; then the rest alphabetically. + Sorted.Sort([](const TPair& A, const TPair& B) + { + if (A.Value != B.Value) + { + return A.Value > B.Value; + } + return A.Key.LexicalLess(B.Key); + }); + + FMenuBuilder MenuBuilder(false, nullptr); + + if (Sorted.IsEmpty()) + { + MenuBuilder.AddWidget( + SNew(STextBlock) + .Text(bAvailableFiltersReady + ? LOCTEXT("NoTypesFound", "No asset types found in the scanned paths.") + : LOCTEXT("TypesNotReady", "Asset Registry is still scanning; reopen this menu shortly.")) + .ColorAndOpacity(FSlateColor::UseSubduedForeground()), + FText::GetEmpty()); + + return MenuBuilder.MakeWidget(); + } + + MenuBuilder.AddMenuEntry( + LOCTEXT("ClearTypes", "Show all types"), + FText::GetEmpty(), + FSlateIcon(), + FUIAction(FExecuteAction::CreateLambda([this]() + { + VisibleTypeNames.Reset(); + RebuildFilteredRows(); + }))); + + // Presets first. The agreed requirement was "presets plus raw classes", and an artist looking + // for VFX should not have to know that it means NiagaraSystem and not ParticleSystem. The raw + // list stays below for anything a preset does not cover. + MenuBuilder.BeginSection(TEXT("Presets"), LOCTEXT("PresetSection", "Presets")); + { + const TSet Available(AvailableTypes); + + for (EAssetTypePreset Preset : GetAllAssetTypePresets()) + { + // Resolve to the short names this menu deals in, expanded through subclasses: without + // expansion "Material" would miss every MaterialInstanceConstant, which is most of what + // a project actually contains. + TSet PresetTypes = ResolvePresetTypeNames(Preset); + + // Only offer what the project has. A preset that selects nothing looks broken, and this + // project has no skeletal meshes at all - offering the entry would invite exactly that + // conclusion. + TSet Present; + for (FName TypeName : PresetTypes) + { + if (Available.Contains(TypeName)) + { + Present.Add(TypeName); + } + } + + if (Present.IsEmpty()) + { + continue; + } + + const FText Label = FText::Format( + LOCTEXT("PresetEntry", "{0} ({1} class(es))"), + FText::FromString(LexToString(Preset)), + FText::AsNumber(Present.Num())); + + MenuBuilder.AddMenuEntry( + Label, + FText::Format( + LOCTEXT("PresetEntryTip", "Select the {0} classes present in this project. Adds to the current selection."), + FText::FromString(LexToString(Preset))), + FSlateIcon(), + FUIAction( + FExecuteAction::CreateLambda([this, Present]() + { + // Toggle as a group: clicking a fully selected preset clears it, which is + // the only way to undo it without hunting its classes in the raw list. + const bool bAllSelected = Algo::AllOf(Present, [this](FName TypeName) + { + return VisibleTypeNames.Contains(TypeName); + }); + + for (FName TypeName : Present) + { + if (bAllSelected) + { + VisibleTypeNames.Remove(TypeName); + } + else + { + VisibleTypeNames.Add(TypeName); + } + } + + RebuildFilteredRows(); + }), + FCanExecuteAction(), + FIsActionChecked::CreateLambda([this, Present]() + { + return Algo::AllOf(Present, [this](FName TypeName) + { + return VisibleTypeNames.Contains(TypeName); + }); + })), + NAME_None, + EUserInterfaceActionType::Check); + } + } + MenuBuilder.EndSection(); + + MenuBuilder.BeginSection(TEXT("RawClasses"), LOCTEXT("RawClassSection", "All classes")); + + for (const TPair& Entry : Sorted) + { + const FName TypeName = Entry.Key; + + MenuBuilder.AddMenuEntry( + FText::Format(LOCTEXT("TypeEntry", "{0} ({1})"), FText::FromName(TypeName), FText::AsNumber(Entry.Value)), + FText::GetEmpty(), + FSlateIcon(), + FUIAction( + FExecuteAction::CreateLambda([this, TypeName]() + { + if (VisibleTypeNames.Contains(TypeName)) + { + VisibleTypeNames.Remove(TypeName); + } + else + { + VisibleTypeNames.Add(TypeName); + } + RebuildFilteredRows(); + }), + FCanExecuteAction(), + FIsActionChecked::CreateLambda([this, TypeName]() { return VisibleTypeNames.Contains(TypeName); })), + NAME_None, + EUserInterfaceActionType::ToggleButton); + } + + MenuBuilder.EndSection(); + + return SNew(SBox) + .MaxDesiredHeight(600.0f) + [ + SNew(SScrollBox) + + SScrollBox::Slot() + [ + MenuBuilder.MakeWidget() + ] + ]; +} + +void SAssetUsageAuditPanel::OnScopeChanged() +{ + // Deliberately does NOT re-run. Scope is chosen from a dropdown, and "All levels" means 1072 + // levels on this project - kicking that off because someone browsed the list would be a nasty + // surprise. Mark the result stale, say so, and let them press Run. + if (!bHasRun) + { + return; + } + + bResultStale = true; + + // Name the scope rather than just saying it changed: the whole point of the message is that the + // rows on screen answer a different question than the one now selected. + FText ScopeText; + if (LevelFilters.IsEmpty()) + { + ScopeText = LOCTEXT("ScopeAll", "all levels"); + } + else if (LevelFilters.Num() == 1) + { + ScopeText = FText::FromString(FPackageName::GetShortName(*LevelFilters.CreateConstIterator())); + } + else + { + ScopeText = FText::Format(LOCTEXT("ScopeNLevels", "{0} levels"), FText::AsNumber(LevelFilters.Num())); + } + + StatusText = FText::Format( + LOCTEXT("ScopeChanged", "Scope changed to {0}. Press Run Audit to refresh - the rows below are from the previous scope."), + ScopeText); + + FString ScopeDescription; + if (LevelFilters.IsEmpty()) + { + ScopeDescription = TEXT("all levels"); + } + else + { + TArray Names; + Names.Reserve(LevelFilters.Num()); + for (FName Level : LevelFilters) + { + Names.Add(Level.ToString()); + } + Names.Sort(); + ScopeDescription = FString::Join(Names, TEXT(", ")); + } + UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Panel: audit scope changed to '%s'; previous result marked stale."), *ScopeDescription); +} + +TSharedRef SAssetUsageAuditPanel::MakeReferenceFilterMenu() +{ + FMenuBuilder MenuBuilder(false, nullptr); + + const auto AddStrength = [this, &MenuBuilder](EReferenceStrengthFilter Value, const FText& Label, const FText& Tooltip) + { + MenuBuilder.AddMenuEntry( + Label, + Tooltip, + FSlateIcon(), + FUIAction( + FExecuteAction::CreateLambda([this, Value]() + { + ReferenceStrength = Value; + RebuildFilteredRows(); + }), + FCanExecuteAction(), + FIsActionChecked::CreateLambda([this, Value]() { return ReferenceStrength == Value; })), + NAME_None, + EUserInterfaceActionType::RadioButton); + }; + + MenuBuilder.BeginSection("Strength", LOCTEXT("StrengthSection", "How the asset is held")); + + AddStrength(EReferenceStrengthFilter::Any, + LOCTEXT("StrengthAny", "Any"), + LOCTEXT("StrengthAnyTip", "No constraint on reference strength.")); + + AddStrength(EReferenceStrengthFilter::HardOnly, + LOCTEXT("StrengthHard", "Hard only"), + LOCTEXT("StrengthHardTip", "Held by hard references and no soft ones. Always loaded with its referencer.")); + + AddStrength(EReferenceStrengthFilter::SoftOnly, + LOCTEXT("StrengthSoft", "Soft only"), + LOCTEXT("StrengthSoftTip", "Held ONLY by soft references - loaded on demand, nothing forces it to be present. This is where missing-in-build problems come from, and where every external actor edge lands.")); + + AddStrength(EReferenceStrengthFilter::Mixed, + LOCTEXT("StrengthMixed", "Mixed"), + LOCTEXT("StrengthMixedTip", "Both hard and soft references point at it.")); + + AddStrength(EReferenceStrengthFilter::None, + LOCTEXT("StrengthNone", "No incoming edges"), + LOCTEXT("StrengthNoneTip", "Neither hard nor soft. A level itself, or an asset reached only from config or source.")); + + MenuBuilder.EndSection(); + + MenuBuilder.BeginSection("Provenance", LOCTEXT("ProvenanceSection", "Must be reached via (all ticked)")); + + const auto AddProvenance = [this, &MenuBuilder](EAssetUsageProvenance Flag, const FText& Label, const FText& Tooltip) + { + MenuBuilder.AddMenuEntry( + Label, + Tooltip, + FSlateIcon(), + FUIAction( + FExecuteAction::CreateLambda([this, Flag]() + { + if (EnumHasAnyFlags(RequiredProvenance, Flag)) + { + EnumRemoveFlags(RequiredProvenance, Flag); + } + else + { + EnumAddFlags(RequiredProvenance, Flag); + } + RebuildFilteredRows(); + }), + FCanExecuteAction(), + FIsActionChecked::CreateLambda([this, Flag]() { return EnumHasAnyFlags(RequiredProvenance, Flag); })), + NAME_None, + EUserInterfaceActionType::ToggleButton); + }; + + AddProvenance(EAssetUsageProvenance::ExternalActor, + LOCTEXT("ProvExternal", "External actor (OFPA)"), + LOCTEXT("ProvExternalTip", "Reached through a One File Per Actor package - the asset is placed in a level rather than referenced by another asset.")); + + AddProvenance(EAssetUsageProvenance::Sublevel, + LOCTEXT("ProvSublevel", "Sublevel or Level Instance"), + LOCTEXT("ProvSublevelTip", "Reached by crossing into a streaming sublevel or a Level Instance.")); + + AddProvenance(EAssetUsageProvenance::EditorOnly, + LOCTEXT("ProvEditorOnly", "Editor-only edge"), + LOCTEXT("ProvEditorOnlyTip", "At least one incoming edge lacks the Game property, so that path will not survive a cook.")); + + AddProvenance(EAssetUsageProvenance::Redirector, + LOCTEXT("ProvRedirector", "Through a redirector"), + LOCTEXT("ProvRedirectorTip", "The path to this asset passes through an ObjectRedirector left by a rename or move.")); + + AddProvenance(EAssetUsageProvenance::ConfigFile, + LOCTEXT("ProvConfig", "Named in Config"), + LOCTEXT("ProvConfigTip", "A /Game path literal in Config/ names this asset. No asset references it.")); + + AddProvenance(EAssetUsageProvenance::SourceFile, + LOCTEXT("ProvSource", "Named in Source"), + LOCTEXT("ProvSourceTip", "A /Game path literal in Source/ names this asset.")); + + MenuBuilder.EndSection(); + + MenuBuilder.AddSeparator(); + + MenuBuilder.AddMenuEntry( + LOCTEXT("ClearRefFilters", "Clear reference filters"), + FText::GetEmpty(), + FSlateIcon(), + FUIAction(FExecuteAction::CreateLambda([this]() + { + ReferenceStrength = EReferenceStrengthFilter::Any; + RequiredProvenance = EAssetUsageProvenance::None; + RebuildFilteredRows(); + }))); + + return MenuBuilder.MakeWidget(); +} + +TSharedRef SAssetUsageAuditPanel::MakeLevelFilterMenu() +{ + if (!bAvailableFiltersReady) + { + RefreshAvailableFilters(); + } + + FMenuBuilder MenuBuilder(true, nullptr); + + if (AvailableLevels.IsEmpty()) + { + MenuBuilder.AddWidget( + SNew(STextBlock) + .Text(bAvailableFiltersReady + ? LOCTEXT("NoLevelsFound", "No levels found in the scanned paths.") + : LOCTEXT("LevelsNotReady", "Asset Registry is still scanning; reopen this menu shortly.")) + .ColorAndOpacity(FSlateColor::UseSubduedForeground()), + FText::GetEmpty()); + + return MenuBuilder.MakeWidget(); + } + + // Choosing levels SCOPES THE NEXT SWEEP, it does not merely filter the last one. Auditing all + // 1072 levels to then hide 1071 of them would be both slow and misleading: an asset's verdict + // depends on which levels were considered. + // + // A checklist rather than a single pick, because FAssetUsageAuditRequest::LevelPackages has + // always been an array and the tree groups by level: "compare these three locations" is a + // question the resolver could already answer and the UI could not ask. + MenuBuilder.AddMenuEntry( + LOCTEXT("AllLevelsEntry", "All levels"), + LOCTEXT("AllLevelsTip", "Audit every level in the project. Slower, and the level column then lists every level that reaches each asset."), + FSlateIcon(), + FUIAction( + FExecuteAction::CreateLambda([this]() + { + LevelFilters.Reset(); + OnScopeChanged(); + }), + FCanExecuteAction(), + // Checked when nothing is ticked: an empty set means no restriction, which is the same + // thing "All levels" says. + FIsActionChecked::CreateLambda([this]() { return LevelFilters.IsEmpty(); })), + NAME_None, + EUserInterfaceActionType::RadioButton); + + MenuBuilder.AddSeparator(); + + for (const FName& Level : AvailableLevels) + { + MenuBuilder.AddMenuEntry( + FText::FromString(FPackageName::GetShortName(Level)), + FText::Format(LOCTEXT("LevelEntryTip", "Include {0} in the sweep. Press Run Audit to apply."), FText::FromName(Level)), + FSlateIcon(), + FUIAction( + FExecuteAction::CreateLambda([this, Level]() + { + if (LevelFilters.Contains(Level)) + { + LevelFilters.Remove(Level); + } + else + { + LevelFilters.Add(Level); + } + OnScopeChanged(); + }), + FCanExecuteAction(), + FIsActionChecked::CreateLambda([this, Level]() { return LevelFilters.Contains(Level); })), + NAME_None, + EUserInterfaceActionType::ToggleButton); + } + + return SNew(SBox) + .MaxDesiredHeight(600.0f) + [ + SNew(SScrollBox) + + SScrollBox::Slot() + [ + MenuBuilder.MakeWidget() + ] + ]; +} + +// --- Sorting --------------------------------------------------------------------------------- + +EColumnSortMode::Type SAssetUsageAuditPanel::GetSortModeForColumn(FName ColumnId) const +{ + return SortColumn == ColumnId ? SortMode : EColumnSortMode::None; +} + +void SAssetUsageAuditPanel::OnSortChanged(EColumnSortPriority::Type Priority, const FName& ColumnId, EColumnSortMode::Type NewMode) +{ + SortColumn = ColumnId; + SortMode = NewMode; + ApplySorting(); + + // The tree holds its own nodes, so sorting FilteredRows underneath it changes nothing on screen + // until the nodes are rebuilt. A plain refresh here would look like the sort was ignored. + RebuildTree(); + + if (TreeView.IsValid()) + { + TreeView->RequestTreeRefresh(); + } +} + +void SAssetUsageAuditPanel::ApplySorting() +{ + if (SortColumn.IsNone() || SortMode == EColumnSortMode::None) + { + return; + } + + const bool bAscending = SortMode == EColumnSortMode::Ascending; + + FilteredRows.Sort([this, bAscending](const FAssetUsageRowPtr& A, const FAssetUsageRowPtr& B) + { + if (!A.IsValid() || !B.IsValid()) + { + return false; + } + + auto Compare = [bAscending](auto Left, auto Right) + { + return bAscending ? Left < Right : Right < Left; + }; + + if (SortColumn == AuditColumns::Name) + { + return Compare(A->AssetName.ToString(), B->AssetName.ToString()); + } + if (SortColumn == AuditColumns::Path) + { + return Compare(A->PathFromProjectRoot, B->PathFromProjectRoot); + } + if (SortColumn == AuditColumns::Type) + { + return Compare(ShortTypeName(A->ClassPath), ShortTypeName(B->ClassPath)); + } + if (SortColumn == AuditColumns::Verdict) + { + return Compare(static_cast(A->Verdict), static_cast(B->Verdict)); + } + if (SortColumn == AuditColumns::Levels) + { + return Compare(A->Levels.Num(), B->Levels.Num()); + } + if (SortColumn == AuditColumns::Hard) + { + return Compare(A->HardReferenceCount, B->HardReferenceCount); + } + if (SortColumn == AuditColumns::Soft) + { + return Compare(A->SoftReferenceCount, B->SoftReferenceCount); + } + + return false; + }); +} + +// --- Ticking --------------------------------------------------------------------------------- + +ECheckBoxState SAssetUsageAuditPanel::GetRowCheckState(FAssetUsageRowPtr Item) const +{ + if (!Item.IsValid()) + { + return ECheckBoxState::Unchecked; + } + + return CheckedPackages.Contains(Item->PackageName) ? ECheckBoxState::Checked : ECheckBoxState::Unchecked; +} + +void SAssetUsageAuditPanel::OnRowCheckChanged(ECheckBoxState NewState, FAssetUsageRowPtr Item) +{ + if (!Item.IsValid()) + { + return; + } + + if (NewState == ECheckBoxState::Checked) + { + CheckedPackages.Add(Item->PackageName); + } + else + { + CheckedPackages.Remove(Item->PackageName); + } +} + +ECheckBoxState SAssetUsageAuditPanel::GetHeaderCheckState() const +{ + if (FilteredRows.IsEmpty()) + { + return ECheckBoxState::Unchecked; + } + + int32 CheckedCount = 0; + for (const FAssetUsageRowPtr& Row : FilteredRows) + { + if (Row.IsValid() && CheckedPackages.Contains(Row->PackageName)) + { + ++CheckedCount; + } + } + + if (CheckedCount == 0) + { + return ECheckBoxState::Unchecked; + } + + return CheckedCount == FilteredRows.Num() ? ECheckBoxState::Checked : ECheckBoxState::Undetermined; +} + +void SAssetUsageAuditPanel::OnHeaderCheckChanged(ECheckBoxState NewState) +{ + // Acts on the filtered view only: ticking "all" while a filter is active must not silently + // select 45000 rows the user cannot see. + const bool bCheck = NewState != ECheckBoxState::Unchecked; + + for (const FAssetUsageRowPtr& Row : FilteredRows) + { + if (!Row.IsValid()) + { + continue; + } + + if (bCheck) + { + CheckedPackages.Add(Row->PackageName); + } + else + { + CheckedPackages.Remove(Row->PackageName); + } + } +} + +FText SAssetUsageAuditPanel::GetSelectionSummaryText() const +{ + return FText::Format( + LOCTEXT("SelectionSummary", "Showing {0} of {1} rows | {2} ticked"), + FText::AsNumber(FilteredRows.Num()), + FText::AsNumber(AllRows.Num()), + FText::AsNumber(CheckedPackages.Num())); +} + +TArray SAssetUsageAuditPanel::ExpandWithDependencies(const TArray& Seeds, FDependencyExpansion& OutExpansion) const +{ + OutExpansion = FDependencyExpansion(); + OutExpansion.SeedCount = Seeds.Num(); + OutExpansion.TotalCount = Seeds.Num(); + + if (!bIncludeDependencies || Seeds.IsEmpty()) + { + return Seeds; + } + + IAssetRegistry* Registry = IAssetRegistry::Get(); + if (!Registry) + { + // Not fatal: exporting exactly what was ticked is a worse answer, not a wrong one. Say so + // rather than silently returning a narrower set than the checkbox promised. + UE_LOG(LogAssetUsageAuditEditor, Warning, + TEXT("Asset Registry unavailable; exporting only the ticked assets without their dependencies.")); + return Seeds; + } + + const UAssetUsageAuditSettings* Settings = UAssetUsageAuditSettings::Get(); + + AssetDependencyClosure::FOptions ClosureOptions; + if (Settings) + { + // Same exclusions as the audit. An export must not quietly pull in content the report was + // told to ignore. + ClosureOptions.ExcludePackagePaths = Settings->ExcludedPackagePaths; + } + + AssetDependencyClosure::FStats Stats; + TArray Expanded = AssetDependencyClosure::Gather(*Registry, Seeds, ClosureOptions, Stats); + + OutExpansion.TotalCount = Expanded.Num(); + OutExpansion.Detail = Stats.Summarise(); + + return Expanded; +} + +bool SAssetUsageAuditPanel::HasCheckedAssets() const +{ + return CheckedPackages.Num() > 0; +} + +void SAssetUsageAuditPanel::GetCheckedRows(TArray& OutRows) const +{ + OutRows.Reset(); + + for (const FAssetUsageRowPtr& Row : AllRows) + { + if (Row.IsValid() && CheckedPackages.Contains(Row->PackageName)) + { + OutRows.Add(*Row); + } + } +} + +// --- Export ---------------------------------------------------------------------------------- + +FReply SAssetUsageAuditPanel::OnExportReportClicked() +{ + const UAssetUsageAuditSettings* Settings = UAssetUsageAuditSettings::Get(); + const FString DefaultDir = Settings ? Settings->GetResolvedExportDirectory() : FPaths::ProjectSavedDir(); + + FString ChosenDir; + IDesktopPlatform* DesktopPlatform = FDesktopPlatformModule::Get(); + + // No picker means no destination. Falling through with an empty ChosenDir would previously + // have handed the writer a blank path and produced no file with no explanation. + if (!DesktopPlatform) + { + StatusText = LOCTEXT("NoPicker", "Could not open a folder picker (DesktopPlatform is unavailable)."); + UE_LOG(LogAssetUsageAuditEditor, Error, TEXT("Report export: DesktopPlatform module unavailable; cannot ask for a destination folder.")); + return FReply::Handled(); + } + + const void* ParentHandle = FSlateApplication::Get().FindBestParentWindowHandleForDialogs(AsShared()); + if (!DesktopPlatform->OpenDirectoryDialog(ParentHandle, LOCTEXT("PickReportDir", "Choose a folder for the report").ToString(), DefaultDir, ChosenDir)) + { + UE_LOG(LogAssetUsageAuditEditor, Verbose, TEXT("Report export cancelled by the user at the folder picker.")); + return FReply::Handled(); + } + + // Report what is on screen, not the raw result: the filters ARE the user's question, and a + // report that quietly widens them would not match what they are looking at. + FAssetUsageAuditResult Filtered = LastResult; + Filtered.Rows.Reset(); + for (const FAssetUsageRowPtr& Row : FilteredRows) + { + if (Row.IsValid()) + { + Filtered.Rows.Add(*Row); + } + } + + Filtered.Header.AppliedFilters.Add(FString::Printf(TEXT("UI filter: %d of %d rows shown"), FilteredRows.Num(), AllRows.Num())); + + FAssetUsageReportWriter::FOptions Options; + Options.OutputDirectory = ChosenDir; + Options.BaseFileName = FString::Printf(TEXT("AssetUsageReport_%s"), *FDateTime::Now().ToString(TEXT("%Y%m%d_%H%M%S"))); + + const FAssetUsageReportWriter::FResult WriteResult = FAssetUsageReportWriter::Write(Filtered, Options); + + if (WriteResult.bSuccess) + { + StatusText = FText::Format(LOCTEXT("ReportWritten", "Report written: {0}"), FText::FromString(WriteResult.CsvPath)); + } + else + { + StatusText = FText::Format(LOCTEXT("ReportFailed", "Report failed: {0}"), FText::FromString(WriteResult.ErrorMessage)); + } + + return FReply::Handled(); +} + +// The mode label, the button label and the mode menu all lived here. They are gone rather than +// left unused: the export window now owns every one of those choices, and a second place that +// still knew how to phrase them would drift out of step with it. + +FReply SAssetUsageAuditPanel::OnExportFilesClicked() +{ + TArray Rows; + GetCheckedRows(Rows); + + if (Rows.IsEmpty()) + { + return FReply::Handled(); + } + + const UAssetUsageAuditSettings* Settings = UAssetUsageAuditSettings::Get(); + + // Seed the dialog from the project defaults and from whatever this user chose last time. The + // project settings are the team's starting point; the dialog is where one person deviates from + // it for one export, without editing a version-controlled ini to do so. + FAssetExportRequest Request; + Request.Mode = ExportMode; + Request.bIncludeDependencies = bIncludeDependencies; + Request.TargetDirectory = Settings ? Settings->GetResolvedExportDirectory() : FPaths::ProjectSavedDir(); + Request.Layout = Settings ? Settings->ExportLayout : EExportLayout::Flat; + Request.bGroupDependenciesByType = Settings && Settings->bGroupDependenciesByType; + Request.CollisionPolicy = (Settings && Settings->bOverwriteExistingFiles) + ? EExportCollisionPolicy::Overwrite + : EExportCollisionPolicy::Index; + + if (!ShowAssetExportDialog(Request, Rows.Num())) + { + return FReply::Handled(); + } + + // Remember the two the panel owns, so the next export opens where this one left off. + ExportMode = Request.Mode; + bIncludeDependencies = Request.bIncludeDependencies; + SaveUserSettings(); + + UE_LOG(LogAssetUsageAuditEditor, Log, + TEXT("Export requested: %d ticked, mode %s, layout %s, dependencies %s, target '%s'."), + Rows.Num(), + Request.Mode == EFileExportMode::CopyPackages ? TEXT("copy") : TEXT("convert"), + LexToString(Request.Layout), + Request.bIncludeDependencies ? TEXT("on") : TEXT("off"), + *Request.TargetDirectory); + + // Migrate is checked before the mode. It is not a way of arranging copied files - it is the + // engine performing the whole transfer itself, so neither the copy nor the convert path applies. + if (Request.Layout == EExportLayout::Migrate) + { + RunMigrateExport(Rows, Request); + } + else if (Request.Mode == EFileExportMode::CopyPackages) + { + RunCopyExport(Rows, Request); + } + else + { + RunConvertExport(Rows, Request); + } + + return FReply::Handled(); +} + +void SAssetUsageAuditPanel::RunMigrateExport(const TArray& Rows, const FAssetExportRequest& Request) +{ + TArray Packages; + Packages.Reserve(Rows.Num()); + for (const FAssetUsageRow& Row : Rows) + { + Packages.Add(Row.PackageName); + } + + // Checked here as well as in the dialog. Migrate validates the destination only after it has + // been called, and reports the refusal to the Output Log alone - which is exactly how this + // arrived as "the Export button does nothing". + const FText DestinationProblem = ValidateMigrateDestination(Request.TargetDirectory); + if (!DestinationProblem.IsEmpty()) + { + StatusText = DestinationProblem; + + UE_LOG(LogAssetUsageAuditEditor, Error, + TEXT("Migrate refused: '%s' - %s"), *Request.TargetDirectory, *DestinationProblem.ToString()); + + FMessageDialog::Open(EAppMsgType::Ok, DestinationProblem); + return; + } + + // Only the ticked packages are handed over. Migrate walks the dependency graph itself, and + // feeding it our own closure as well would make it re-walk from every dependency - the same + // result, reached slowly, with the engine's report listing assets the user never chose. + FMigrationOptions Options; + Options.bPrompt = false; + Options.bIgnoreDependencies = !Request.bIncludeDependencies; + Options.AssetConflict = (Request.CollisionPolicy == EExportCollisionPolicy::Overwrite) + ? EAssetMigrationConflict::Overwrite + : EAssetMigrationConflict::Skip; + + // The naming policy has no equivalent here. Migrate must land each package at its original + // package path or the references it exists to preserve would break, so "keep both under a new + // name" is not on offer - the closest honest mapping is skip. + if (Request.CollisionPolicy == EExportCollisionPolicy::Index) + { + UE_LOG(LogAssetUsageAuditEditor, Log, + TEXT("Migrate: the Keep-both naming policy does not apply; existing assets at the destination will be skipped.")); + } + + if (Options.bIgnoreDependencies) + { + // Engine comment on the flag, worth repeating where someone can act on it: with dependencies + // ignored, a level's One File Per Actor packages are not migrated either, so the level + // arrives empty rather than merely unreferenced. + const EAppReturnType::Type Answer = FMessageDialog::Open(EAppMsgType::YesNo, + LOCTEXT("MigrateNoDeps", + "Migrate without dependencies?\n\n" + "The assets will arrive with their references unresolved, and any level among them " + "will arrive empty - Unreal skips the One File Per Actor packages when dependencies " + "are ignored.\n\n" + "Tick \"Include referenced assets\" unless you know the destination already has them.")); + + if (Answer != EAppReturnType::Yes) + { + UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Migrate cancelled at the no-dependencies warning.")); + return; + } + } + + UE_LOG(LogAssetUsageAuditEditor, Log, + TEXT("Migrating %d ticked package(s) to '%s'. Dependencies %s, conflicts %s."), + Packages.Num(), + *Request.TargetDirectory, + Options.bIgnoreDependencies ? TEXT("ignored") : TEXT("included"), + Options.AssetConflict == EAssetMigrationConflict::Overwrite ? TEXT("overwritten") : TEXT("skipped")); + + IAssetTools::Get().MigratePackages(Packages, Request.TargetDirectory, Options); + + // MigratePackages returns void and reports through its own notifications, so there is no count + // to quote here. Saying "N files copied" would be an invention. + StatusText = FText::Format( + LOCTEXT("MigrateStarted", "Migrate handed {0} asset(s) to Unreal for transfer to {1}. Unreal reports the outcome itself - check the Output Log and any notification it raises."), + FText::AsNumber(Packages.Num()), + FText::FromString(Request.TargetDirectory)); +} + +void SAssetUsageAuditPanel::RunCopyExport(const TArray& Rows, const FAssetExportRequest& Request) +{ + const UAssetUsageAuditSettings* Settings = UAssetUsageAuditSettings::Get(); + + const FString& TargetDirectory = Request.TargetDirectory; + + TArray Seeds; + Seeds.Reserve(Rows.Num()); + for (const FAssetUsageRow& Row : Rows) + { + Seeds.Add(Row.PackageName); + } + + const EExportLayout Layout = Request.Layout; + + IAssetRegistry* Registry = IAssetRegistry::Get(); + + // Folder-per-asset needs to know which seed pulled each dependency in, so it takes the slower + // per-seed closure. The other layouts collapse to one list and use the cheap path. + TArray Closures; + FDependencyExpansion Expansion; + + if (Layout == EExportLayout::FolderPerAsset && Request.bIncludeDependencies && Registry) + { + AssetDependencyClosure::FOptions ClosureOptions; + if (Settings) + { + ClosureOptions.ExcludePackagePaths = Settings->ExcludedPackagePaths; + } + + AssetDependencyClosure::FStats ClosureStats; + Closures = AssetDependencyClosure::GatherPerSeed(*Registry, Seeds, ClosureOptions, ClosureStats); + + Expansion.SeedCount = ClosureStats.SeedCount; + Expansion.TotalCount = ClosureStats.TotalCount; + Expansion.Detail = ClosureStats.Summarise(); + } + else + { + const TArray PackageNames = ExpandWithDependencies(Seeds, Expansion); + + AssetDependencyClosure::FSeedClosure& Single = Closures.AddDefaulted_GetRef(); + Single.Packages = PackageNames; + } + + AssetExportLayout::FOptions LayoutOptions; + LayoutOptions.Layout = Layout; + LayoutOptions.bGroupDependenciesByType = Request.bGroupDependenciesByType; + + AssetExportLayout::FStats LayoutStats; + const TArray LayoutGroups = Registry + ? AssetExportLayout::BuildGroups(*Registry, Closures, LayoutOptions, LayoutStats) + : TArray(); + + // Ask before writing several times more files than were ticked. Without this the folder simply + // arrives much larger than expected, and the natural conclusion is that the tool is broken. + // + // Under folder-per-asset the number that matters is placements, not distinct packages: a shared + // texture is written once per folder, so the file count can far exceed the asset count. + if (LayoutStats.FilePlacements > Expansion.SeedCount) + { + const FText Detail = (Layout == EExportLayout::FolderPerAsset) + ? FText::Format( + LOCTEXT("CopyConfirmFolders", + "Write {0} files into {1} folders?\n\n" + "You ticked {2} assets. Each gets its own folder with the materials, textures and " + "other assets it references beside it, so anything shared is copied into every " + "folder that needs it - {3} distinct assets become {0} files."), + FText::AsNumber(LayoutStats.FilePlacements), + FText::AsNumber(LayoutStats.GroupCount), + FText::AsNumber(Expansion.SeedCount), + FText::AsNumber(LayoutStats.DistinctPackages)) + : FText::Format( + LOCTEXT("CopyConfirmDeps", + "Copy {0} files?\n\n" + "You ticked {1}; the other {2} are materials, textures and other assets they reference. " + "Without those, the exported files open with missing content.\n\n" + "Untick \"Include dependencies\" to copy only what you ticked."), + FText::AsNumber(LayoutStats.FilePlacements), + FText::AsNumber(Expansion.SeedCount), + FText::AsNumber(FMath::Max(0, LayoutStats.FilePlacements - Expansion.SeedCount))); + + if (FMessageDialog::Open(EAppMsgType::YesNo, Detail) != EAppReturnType::Yes) + { + UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Copy cancelled at the confirmation dialog.")); + return; + } + + UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Dependency expansion: %s"), *Expansion.Detail); + } + + FAssetUsageExporter::FOptions ExportOptions; + ExportOptions.TargetDirectory = Request.TargetDirectory; + ExportOptions.CollisionPolicy = Request.CollisionPolicy; + // bFlatten is the exporter's mirror-the-tree switch, applied inside whatever folder a group + // lands in. The folder-per-asset layout wants flat contents within each asset's own folder, + // so only MirrorTree turns it off. + ExportOptions.bFlatten = Request.Layout != EExportLayout::MirrorTree; + ExportOptions.bRecordWrittenFiles = Request.bWriteManifest; + + UE_LOG(LogAssetUsageAuditEditor, Log, + TEXT("Copying to '%s': layout %s, %d group(s), %d file(s) from %d distinct package(s)."), + *TargetDirectory, + LexToString(Layout), + LayoutStats.GroupCount, + LayoutStats.FilePlacements, + LayoutStats.DistinctPackages); + + FScopedSlowTask SlowTask(static_cast(LayoutStats.FilePlacements), LOCTEXT("Exporting", "Copying asset files...")); + SlowTask.MakeDialog(true); + + // Returning false cancels; the exporter keeps whatever it has already copied. + ExportOptions.OnProgress = [&SlowTask](int32, int32) + { + SlowTask.EnterProgressFrame(1.0f); + return !SlowTask.ShouldCancel(); + }; + + const FAssetUsageExporter::FResult ExportResult = FAssetUsageExporter::ExportPackageGroups(LayoutGroups, ExportOptions); + + // The manifest describes what was written, so it is written after the copy and only when the + // copy produced something. Writing it first would leave a manifest describing files that a + // cancelled or failed export never created. + if (Request.bWriteManifest && Registry && ExportResult.FilesCopied > 0) + { + AssetExportManifest::FOptions ManifestOptions; + ManifestOptions.TargetDirectory = Request.TargetDirectory; + ManifestOptions.Layout = Request.Layout; + ManifestOptions.CollisionPolicy = Request.CollisionPolicy; + ManifestOptions.SeedPackages = TSet(Seeds); + + const AssetExportManifest::FResult ManifestResult = + AssetExportManifest::Write(*Registry, ExportResult.WrittenFiles, ManifestOptions); + + if (!ManifestResult.bSuccess) + { + // Not fatal - the files are already on disk and are what the user asked for. Said out + // loud all the same: silently skipping it would leave someone believing they can restore + // the references later. + UE_LOG(LogAssetUsageAuditEditor, Warning, + TEXT("Files were exported but the dependency manifest was not written: %s"), + *ManifestResult.ErrorMessage); + } + } + + StatusText = FText::Format( + LOCTEXT("ExportDone", "Export to {0}: {1}"), + FText::FromString(TargetDirectory), + FText::FromString(ExportResult.Summarise())); + + if (!ExportResult.Errors.IsEmpty()) + { + FMessageDialog::Open(EAppMsgType::Ok, FText::Format( + LOCTEXT("ExportHadFailures", "{0} file(s) could not be copied. See the Output Log for details."), + FText::AsNumber(ExportResult.Errors.Num()))); + } +} + +void SAssetUsageAuditPanel::RunConvertExport(const TArray& Rows, const FAssetExportRequest& Request) +{ + const UAssetUsageAuditSettings* Settings = UAssetUsageAuditSettings::Get(); + + const FString& TargetDirectory = Request.TargetDirectory; + + TArray Seeds; + Seeds.Reserve(Rows.Num()); + + // Rows carry the asset name and class already; dependencies do not, and asking the registry for + // every package would be wasteful when most of them are right here. + TMap RowByPackage; + RowByPackage.Reserve(Rows.Num()); + + for (const FAssetUsageRow& Row : Rows) + { + Seeds.Add(Row.PackageName); + RowByPackage.Add(Row.PackageName, &Row); + } + + // Honour the dialog's choice rather than the panel's remembered one: the user may have just + // unticked dependencies for this export alone. + FDependencyExpansion Expansion; + const TArray Packages = Request.bIncludeDependencies + ? ExpandWithDependencies(Seeds, Expansion) + : Seeds; + + if (!Request.bIncludeDependencies) + { + Expansion.SeedCount = Seeds.Num(); + Expansion.TotalCount = Seeds.Num(); + } + + IAssetRegistry* Registry = IAssetRegistry::Get(); + + TArray Items; + Items.Reserve(Packages.Num()); + + for (FName PackageName : Packages) + { + if (const FAssetUsageRow* const* Found = RowByPackage.Find(PackageName)) + { + FAssetUsageExporter::FExportItem& Item = Items.AddDefaulted_GetRef(); + Item.PackageName = (*Found)->PackageName; + Item.AssetName = (*Found)->AssetName; + Item.ClassPath = (*Found)->ClassPath; + continue; + } + + if (!Registry) + { + continue; + } + + // A package can hold several assets. Take them all: skipping the extras would drop content + // silently, and the format lookup rejects anything unconvertible anyway. + TArray AssetsInPackage; + Registry->GetAssetsByPackageName(PackageName, AssetsInPackage, /*bIncludeOnlyOnDiskAssets=*/true); + + for (const FAssetData& AssetData : AssetsInPackage) + { + FAssetUsageExporter::FExportItem& Item = Items.AddDefaulted_GetRef(); + Item.PackageName = AssetData.PackageName; + Item.AssetName = AssetData.AssetName; + Item.ClassPath = AssetData.AssetClassPath; + } + } + + FAssetUsageExporter::FExchangeOptions ConvertOptions; + ConvertOptions.TargetDirectory = TargetDirectory; + ConvertOptions.CollisionPolicy = Request.CollisionPolicy; + ConvertOptions.bFlatten = Request.Layout != EExportLayout::MirrorTree; + + if (Settings) + { + ConvertOptions.FormatByClass = Settings->ExchangeFormatByClass; + } + + // Warn before starting rather than after: conversion loads every asset, and on a few thousand + // rows that is minutes of a blocked editor. Someone who ticked half the project and picked the + // wrong mode should find that out here. + const TMap& EffectiveFormats = ConvertOptions.FormatByClass.Num() > 0 + ? ConvertOptions.FormatByClass + : FAssetUsageExporter::GetDefaultFormatByClass(); + + int32 Convertible = 0; + for (const FAssetUsageExporter::FExportItem& Item : Items) + { + if (!FAssetUsageExporter::FindFormatForClass(Item.ClassPath, EffectiveFormats).IsEmpty()) + { + ++Convertible; + } + } + + if (Convertible == 0) + { + StatusText = LOCTEXT("ConvertNothing", "None of the ticked assets have a configured exchange format."); + UE_LOG(LogAssetUsageAuditEditor, Warning, + TEXT("Conversion not started: none of the %d ticked assets map to a format. Check Project Settings > Asset Usage Audit > Exchange Format By Class."), + Items.Num()); + FMessageDialog::Open(EAppMsgType::Ok, LOCTEXT("ConvertNothingDialog", + "None of the ticked assets have a configured exchange format.\n\n" + "Set one in Project Settings > Asset Usage Audit > Exchange Format By Class, or use the copy mode instead.")); + return; + } + + // The count shown must be the expanded one, since that is what will actually be processed. + // Saying "of N ticked" here after silently widening the set would be a lie of exactly the kind + // that makes people stop trusting a number. + const FText Origin = Expansion.AddedCount() > 0 + ? FText::Format( + LOCTEXT("ConvertOriginExpanded", "{0} ticked plus {1} they reference"), + FText::AsNumber(Expansion.SeedCount), + FText::AsNumber(Expansion.AddedCount())) + : FText::Format(LOCTEXT("ConvertOriginPlain", "{0} ticked"), FText::AsNumber(Expansion.SeedCount)); + + const EAppReturnType::Type Answer = FMessageDialog::Open(EAppMsgType::YesNo, FText::Format( + LOCTEXT("ConvertConfirm", + "Convert {0} of {1} assets ({2})?\n\n" + "{3} have no configured format and will be skipped.\n\n" + "Conversion loads every asset, so this can take several minutes and cannot be undone once files are written."), + FText::AsNumber(Convertible), + FText::AsNumber(Items.Num()), + Origin, + FText::AsNumber(Items.Num() - Convertible))); + + if (Answer != EAppReturnType::Yes) + { + UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Conversion cancelled at the confirmation dialog.")); + return; + } + + UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Converting %d of %d ticked asset(s) into '%s'."), Convertible, Items.Num(), *TargetDirectory); + + FScopedSlowTask SlowTask(static_cast(Items.Num()), LOCTEXT("Converting", "Converting assets...")); + SlowTask.MakeDialog(true); + + ConvertOptions.OnProgress = [&SlowTask](int32, int32) + { + SlowTask.EnterProgressFrame(1.0f); + return !SlowTask.ShouldCancel(); + }; + + const FAssetUsageExporter::FExchangeResult ConvertResult = FAssetUsageExporter::ExportConvertedAssets(Items, ConvertOptions); + + StatusText = FText::Format( + LOCTEXT("ConvertDone", "Conversion to {0}: {1}"), + FText::FromString(TargetDirectory), + FText::FromString(ConvertResult.Summarise())); + + if (!ConvertResult.Errors.IsEmpty()) + { + FMessageDialog::Open(EAppMsgType::Ok, FText::Format( + LOCTEXT("ConvertHadFailures", "{0} asset(s) could not be converted. See the Output Log for details."), + FText::AsNumber(ConvertResult.Errors.Num()))); + } +} + +#undef LOCTEXT_NAMESPACE diff --git a/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.h b/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.h new file mode 100644 index 0000000..ab125f5 --- /dev/null +++ b/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.h @@ -0,0 +1,377 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" +#include "AssetUsageAuditor.h" +#include "SAssetExportDialog.h" +#include "Widgets/SCompoundWidget.h" +#include "Widgets/Views/SHeaderRow.h" +#include "Widgets/Views/SListView.h" +#include "Widgets/Views/STreeView.h" + +/** One row as the list view sees it. Shared so the list can hold it without copying. */ +using FAssetUsageRowPtr = TSharedPtr; + +/** + * A node in the results tree: either a level heading or an asset under one. + * + * The tree exists because "by level" needs to answer two questions at once - what is on this + * location, and how do two locations compare - and a flat list filtered to one level can only + * answer the first. In by-asset mode every node is an asset with no children, so the same widget + * serves both directions and there is one code path to keep correct rather than two. + */ +struct FAuditTreeItem +{ + /** Set on a level heading. Mutually exclusive with Row. */ + FName LevelPackage; + + /** Set on an asset node. */ + FAssetUsageRowPtr Row; + + /** Assets under a level heading. Always empty for an asset node. */ + TArray> Children; + + bool IsLevel() const + { + return !LevelPackage.IsNone(); + } +}; + +using FAuditTreeItemPtr = TSharedPtr; + +/** + * Filter on how an asset is held. + * + * The interesting value is SoftOnly. An asset reached exclusively through soft references is + * loaded on demand and nothing forces it to be present - that is where "it worked in the editor + * and vanished in the build" comes from. Hard vs soft is a property of the edge, never of the + * query (see AssetUsageAudit::MakeTraversalQuery), so this filters the result, not the sweep. + */ +enum class EReferenceStrengthFilter : uint8 +{ + /** No constraint. */ + Any, + + /** At least one hard reference and no soft ones. */ + HardOnly, + + /** Held only by soft references. */ + SoftOnly, + + /** Both kinds present. */ + Mixed, + + /** Neither - reached only as a level seed, from config, or not reached at all. */ + None +}; + +/** + * What "export ticked files" writes. + * + * Both modes were agreed, and they answer different questions. A copy is byte-exact and cheap, and + * is what you want when the files are going into another Unreal project. A conversion is what you + * want when they are going to someone who does not run Unreal at all - and it costs a full asset + * load per file, so it is never the silent default. + */ +// EFileExportMode now lives in SAssetExportDialog.h: the dialog owns the mode switch, and keeping +// the definition here would make the two headers include each other. + +/** + * Result of widening a ticked set to its dependency closure. + * + * Carries the before and after counts because the ratio is the whole point of showing a dialog: + * "40 ticked, 380 will be written" is the number that stops someone exporting half the project. + */ +struct FDependencyExpansion +{ + /** What the user ticked. */ + int32 SeedCount = 0; + + /** What will actually be written, seeds included. Equals SeedCount when the option is off. */ + int32 TotalCount = 0; + + /** Human-readable breakdown for the Output Log. */ + FString Detail; + + int32 AddedCount() const + { + return FMath::Max(0, TotalCount - SeedCount); + } +}; + +/** Which question the user is asking. Both directions live in one window. */ +enum class EAuditViewMode : uint8 +{ + /** Rows are assets; the Levels column says where each is used. */ + ByAsset, + + /** Rows are grouped under the level that uses them. */ + ByLevel +}; + +/** + * The audit window. + * + * Holds the last result, a filtered view of it, and the user's tick marks. Runs nothing itself - + * every calculation goes through FAssetUsageAuditor in the Core module, so the panel and the + * console commands cannot diverge. + */ +class SAssetUsageAuditPanel : public SCompoundWidget +{ +public: + SLATE_BEGIN_ARGS(SAssetUsageAuditPanel) {} + SLATE_END_ARGS() + + void Construct(const FArguments& InArgs); + + /** Persists the filter state. The tab is destroyed on close, which is when this fires. */ + virtual ~SAssetUsageAuditPanel() override; + +private: + // --- Running ----------------------------------------------------------------------------- + + FReply OnRunClicked(); + bool CanRun() const; + void RunAudit(); + + // --- List -------------------------------------------------------------------------------- + + TSharedRef OnGenerateRow(FAuditTreeItemPtr Item, const TSharedRef& OwnerTable); + void OnGetChildren(FAuditTreeItemPtr Item, TArray& OutChildren); + + /** + * Double-click reveals the asset in the Content Browser, the same as Browse To in the editor. + * + * Deliberately the FAssetData overload of SyncBrowserToObjects rather than the UObject one: + * revealing a row must not load it. On this project a row can be a 200 MB mesh or a whole map, + * and loading it to point at it would stall the editor for seconds with no visible reason. + * + * A level heading syncs to the .umap itself, which is what someone double-clicking a location + * is asking for. + */ + void OnItemDoubleClicked(FAuditTreeItemPtr Item); + void RebuildFilteredRows(); + + /** + * Rebuild the tree from FilteredRows. + * + * By-asset mode produces one childless node per row; by-level mode groups rows under the levels + * that use them. A row on several levels appears under each - deliberately, because "which + * locations use this" is the other half of the question the tool answers. + */ + void RebuildTree(); + + /** Ticking a level heading ticks everything under it. */ + ECheckBoxState GetLevelCheckState(FAuditTreeItemPtr Item) const; + void OnLevelCheckChanged(ECheckBoxState NewState, FAuditTreeItemPtr Item); + + /** Root nodes: assets in by-asset mode, levels in by-level mode. */ + TArray RootItems; + + // --- Filtering --------------------------------------------------------------------------- + + bool PassesFilters(const FAssetUsageRow& Row) const; + void OnSearchTextChanged(const FText& NewText); + + TSharedRef MakeVerdictFilterMenu(); + TSharedRef MakeTypeFilterMenu(); + + /** + * Short class names a type preset stands for, expanded through subclasses. + * + * Expansion is the point: "Material" without it misses every MaterialInstanceConstant, which on + * this project is most of what exists. The menu deals in short names because that is what + * PassesFilters compares against, so the expanded class paths are reduced to their asset names. + */ + TSet ResolvePresetTypeNames(enum class EAssetTypePreset Preset) const; + + // --- Persisted panel state --------------------------------------------------------------- + + /** + * Restore the previous session's filters. + * + * Every value is validated rather than trusted: the ini is a text file a user can edit, and an + * out-of-range enum read straight into a switch is a crash on startup of the editor. + */ + void LoadUserSettings(); + + /** + * Persist the current filters. + * + * Called from the destructor, which fires when the tab is closed or the editor shuts down + * normally. Saving on every filter change instead would mean an ini write per keystroke in the + * search box, and SaveConfig is a synchronous disk write. + * + * Known consequence: an editor crash loses the session's filter changes. Accepted rather than + * worked around - the state is cheap to recreate, and the alternative costs disk traffic on + * every interaction. + */ + void SaveUserSettings() const; + TSharedRef MakeLevelFilterMenu(); + TSharedRef MakeReferenceFilterMenu(); + + /** + * Fill the level and type dropdowns from the Asset Registry. + * + * Deliberately NOT derived from the last result. Building them from result rows left both + * menus empty until Run Audit had been pressed, which reads as a broken tool: you open the + * panel, click "All levels", and nothing is there. + * + * It also matters for correctness of the workflow, not just for looks. The task is "assets of + * a chosen type for a chosen location", so the level has to be pickable BEFORE the sweep - + * a menu populated from results can only ever filter a sweep that already happened. + * + * Cached: enumerating the project costs a fraction of a second and the content does not change + * under the user mid-session. Refreshed on demand and after each audit. + */ + void RefreshAvailableFilters(); + + /** + * The chosen level scopes the sweep, so changing it invalidates the current result rather + * than filtering it. Marks the result stale and tells the user to press Run Audit, instead + * of showing figures that answer a different question than the one on screen. + */ + void OnScopeChanged(); + + /** True when the level scope changed after the last sweep. */ + bool bResultStale = false; + + /** Level packages offered by the level dropdown, sorted by name. */ + TArray AvailableLevels; + + /** Short type names offered by the type dropdown, including Blueprint generated classes. */ + TArray AvailableTypes; + + /** False until RefreshAvailableFilters has completed a full pass. */ + bool bAvailableFiltersReady = false; + + // --- Sorting ----------------------------------------------------------------------------- + + EColumnSortMode::Type GetSortModeForColumn(FName ColumnId) const; + void OnSortChanged(EColumnSortPriority::Type Priority, const FName& ColumnId, EColumnSortMode::Type NewMode); + void ApplySorting(); + + // --- Ticking assets for export ----------------------------------------------------------- + + /** + * Checked packages, keyed by package name rather than by row pointer. + * + * Deliberately NOT the list view's selection. Selection is rebuilt whenever the filter changes + * or the audit re-runs, so using it would silently discard ticks the user had already made - + * they would filter, tick forty assets, change the type filter and find their work gone. + * Keying by FName means ticks survive both. + */ + TSet CheckedPackages; + + ECheckBoxState GetRowCheckState(FAssetUsageRowPtr Item) const; + void OnRowCheckChanged(ECheckBoxState NewState, FAssetUsageRowPtr Item); + + ECheckBoxState GetHeaderCheckState() const; + void OnHeaderCheckChanged(ECheckBoxState NewState); + + FText GetSelectionSummaryText() const; + + // --- Export ------------------------------------------------------------------------------ + + FReply OnExportReportClicked(); + FReply OnExportFilesClicked(); + bool HasCheckedAssets() const; + + /** Byte copy of the package files, expanding levels to their OFPA packages. */ + void RunCopyExport(const TArray& Rows, const FAssetExportRequest& Request); + + /** Load and convert through UExporter. Separate function because the failure modes differ. */ + void RunConvertExport(const TArray& Rows, const FAssetExportRequest& Request); + + /** + * Hand the packages to IAssetTools::MigratePackages. + * + * The only export whose result opens in another project with references intact: Migrate writes + * each package to the same package path under the destination Content folder, which is what a + * .uasset's stored references need in order to resolve. + * + * Lives here rather than in Core because AssetTools is editor-only, and reports nothing back - + * MigratePackages returns void and shows the engine's own report - so this cannot pretend to + * count files the way the copy path does. + */ + void RunMigrateExport(const TArray& Rows, const FAssetExportRequest& Request); + + EFileExportMode ExportMode = EFileExportMode::CopyPackages; + + /** Rows currently ticked, resolved against the full result rather than the filtered view. */ + void GetCheckedRows(TArray& OutRows) const; + + /** + * Pull in everything the ticked assets reference, so an exported mesh arrives with its + * materials and textures instead of opening pink. + * + * On by default. Ticking eleven meshes and receiving eleven unusable files is the more + * surprising of the two behaviours, and the confirmation dialog states the real figure before + * anything is written. + */ + bool bIncludeDependencies = true; + + /** Seeds plus their dependency closure, or the seeds unchanged when the option is off. */ + TArray ExpandWithDependencies(const TArray& Seeds, struct FDependencyExpansion& OutExpansion) const; + + // --- State ------------------------------------------------------------------------------- + + FAssetUsageAuditResult LastResult; + TArray AllRows; + TArray FilteredRows; + + TSharedPtr> TreeView; + TSharedPtr HeaderRow; + + EAuditViewMode ViewMode = EAuditViewMode::ByAsset; + + FString SearchText; + TSet VisibleVerdicts; + + /** + * Asset classes to show, e.g. StaticMesh, Texture2D, Blueprint. + * + * Only the asset's own class goes here. Blueprint generated classes are deliberately kept out: + * folding them in produced a 2980-entry dropdown on this project, where someone looking for + * "StaticMesh" had to scroll past thousands of BP_Something_C entries. The gameplay class is + * a different question and gets its own text filter below. + */ + TSet VisibleTypeNames; + + /** + * Substring match against a Blueprint's generated gameplay class. + * + * A separate axis from the asset class: every Blueprint asset is /Script/Engine.Blueprint, so + * "which Blueprints derive from something Pickup-shaped" cannot be answered by asset class. + */ + FString GeneratedClassFilter; + + /** How the asset must be held for its row to show. */ + EReferenceStrengthFilter ReferenceStrength = EReferenceStrengthFilter::Any; + + /** + * Provenance flags a row must carry, ANDed together. + * + * None means no constraint. Combining flags answers questions the columns alone cannot, e.g. + * "editor-only AND reached through an external actor" - content placed in a level that will + * not survive a cook. + */ + EAssetUsageProvenance RequiredProvenance = EAssetUsageProvenance::None; + + /** + * Levels the audit is scoped to. Empty means every level in the project. + * + * A set rather than a single name, and the same shape as VisibleTypeNames, because the two + * menus do the same job and a checklist beside a radio list is a UI that has to be explained. + * FAssetUsageAuditRequest::LevelPackages was already an array - the panel was the only thing + * limiting this to one. + */ + TSet LevelFilters; + + FName SortColumn; + EColumnSortMode::Type SortMode = EColumnSortMode::None; + + bool bHasRun = false; + FText StatusText; +}; diff --git a/Source/AssetUsageAuditEditor/Public/AssetUsageAuditEditorModule.h b/Source/AssetUsageAuditEditor/Public/AssetUsageAuditEditorModule.h new file mode 100644 index 0000000..6acff66 --- /dev/null +++ b/Source/AssetUsageAuditEditor/Public/AssetUsageAuditEditorModule.h @@ -0,0 +1,43 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" +#include "Modules/ModuleManager.h" + +class IConsoleObject; +class SDockTab; +class FSpawnTabArgs; + +ASSETUSAGEAUDITEDITOR_API DECLARE_LOG_CATEGORY_EXTERN(LogAssetUsageAuditEditor, Log, All); + +/** + * Editor-side host: settings, console entry points and (from phase 3) the Slate panel. + * + * All analysis lives in AssetUsageAuditCore. This module only drives it and presents results, + * so a commandlet can reach exactly the same code path without dragging UI in. + */ +class FAssetUsageAuditEditorModule : public IModuleInterface +{ +public: + virtual void StartupModule() override; + virtual void ShutdownModule() override; + + /** Nomad tab id, also used as the invoke target from the Tools menu. */ + static const FName PanelTabId; + +private: + void RegisterConsoleCommands(); + void UnregisterConsoleCommands(); + + void RegisterTabSpawner(); + void UnregisterTabSpawner(); + void RegisterMenus(); + + TSharedRef SpawnPanelTab(const FSpawnTabArgs& Args); + + /** Shared implementation behind every console entry point. */ + void ExecuteAudit(const TArray& Args, bool bOnlyUnused); + + TArray ConsoleCommands; +}; diff --git a/Source/AssetUsageAuditEditor/Public/AssetUsageAuditLibrary.h b/Source/AssetUsageAuditEditor/Public/AssetUsageAuditLibrary.h new file mode 100644 index 0000000..0a08b75 --- /dev/null +++ b/Source/AssetUsageAuditEditor/Public/AssetUsageAuditLibrary.h @@ -0,0 +1,61 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "AssetUsageAuditLibrary.generated.h" + +/** + * Blueprint and Python entry points for the audit. + * + * Phase 1 uses these as the only way to run the analysis, before the Slate panel exists. They + * remain useful afterwards: a TA can script a one-off audit from an Editor Utility Widget without + * touching the main tool, and the same calls drive the Python bridge. + * + * These are thin wrappers. All logic lives in AssetUsageAuditCore so that the panel, these + * functions and a future commandlet cannot drift apart. + */ +UCLASS() +class ASSETUSAGEAUDITEDITOR_API UAssetUsageAuditLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + /** + * Run a full audit and write JSON + CSV reports. + * + * @param LevelPackageNames Levels to analyse, e.g. "/Game/Space/Maps/WP_Main". Empty means all. + * @param OutputDirectory Destination. Empty uses the configured default. + * @param OutReportPath Absolute path of the JSON report on success. + * @return true when both reports were written. + */ + UFUNCTION(BlueprintCallable, Category = "Asset Usage Audit", meta = (AutoCreateRefTerm = "LevelPackageNames")) + static bool RunAudit(const TArray& LevelPackageNames, const FString& OutputDirectory, FString& OutReportPath); + + /** + * Assets reachable from one level, resolved transitively through Blueprints and other assets. + * + * This is the question the tool exists to answer, exposed on its own for scripting. + */ + UFUNCTION(BlueprintCallable, Category = "Asset Usage Audit") + static TArray GetAssetsUsedOnLevel(const FString& LevelPackageName); + + /** + * Levels that use the given asset. + * + * Includes both a sublevel and its parent map when the asset sits in a sublevel, because the + * parent's traversal reaches through ULevelStreaming::WorldAsset. + */ + UFUNCTION(BlueprintCallable, Category = "Asset Usage Audit") + static TArray GetLevelsUsingAsset(const FString& AssetPackageName); + + /** + * Time a full sweep and log the breakdown. + * + * Exists to answer the open question in the plan: whether an in-memory graph cache is needed + * at all. Measure before adding one. + */ + UFUNCTION(BlueprintCallable, Category = "Asset Usage Audit") + static float MeasureFullSweepSeconds(); +}; diff --git a/Source/AssetUsageAuditEditor/Public/AssetUsageAuditSettings.h b/Source/AssetUsageAuditEditor/Public/AssetUsageAuditSettings.h new file mode 100644 index 0000000..fa6f4a6 --- /dev/null +++ b/Source/AssetUsageAuditEditor/Public/AssetUsageAuditSettings.h @@ -0,0 +1,90 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" +#include "AssetUsageAuditTypes.h" +#include "Engine/DeveloperSettings.h" +#include "AssetUsageAuditSettings.generated.h" + +/** + * Team-shared configuration, written to Config/DefaultEditor.ini. + * + * That file is Perforce-tracked (only Plugins/** is p4-ignored), so the exclusion list and the + * type presets are reviewable and shared. Per-user state - last filter, column layout - belongs + * in UAssetUsageAuditUserSettings instead, which stays in Saved/Config. + */ +UCLASS(config = Editor, defaultconfig, meta = (DisplayName = "Asset Usage Audit")) +class ASSETUSAGEAUDITEDITOR_API UAssetUsageAuditSettings : public UDeveloperSettings +{ + GENERATED_BODY() + +public: + UAssetUsageAuditSettings(); + + /** Default folder for exported files and reports. Empty means /Saved/AssetUsageAudit. */ + UPROPERTY(config, EditAnywhere, Category = "Export", meta = (ToolTip = "Default destination for exported assets and reports. Leave empty to use Saved/AssetUsageAudit.")) + FString DefaultExportDirectory; + + /** Overwrite existing files on export, or add/increment a numeric suffix. */ + UPROPERTY(config, EditAnywhere, Category = "Export", meta = (ToolTip = "When a file of the same name already exists: replace it, or write Foo_1, Foo_2 and so on.")) + bool bOverwriteExistingFiles = false; + + /** + * Asset class short name -> interchange file extension, used by the "Convert" export mode. + * + * Empty falls back to the built-in table. Lookup follows the class hierarchy, so one entry for + * MaterialInterface would cover every material instance. Only add a class the engine ships a + * UExporter for: an entry with no exporter behind it produces no file and reads as a bug. + */ + UPROPERTY(config, EditAnywhere, Category = "Export", meta = (ToolTip = "Which file format each asset class converts to, e.g. StaticMesh -> fbx. Leave empty for the built-in defaults.")) + TMap ExchangeFormatByClass; + + /** + * How exported files are arranged under the destination. + * + * Replaced an earlier bMirrorFolderStructure boolean. Adding "one folder per asset" as a second + * boolean would have made "mirror the tree AND a folder per asset" expressible, and it means + * nothing - the enum makes the three arrangements mutually exclusive, which they are. + */ + UPROPERTY(config, EditAnywhere, Category = "Export", meta = (ToolTip = "Flat: every file side by side. Mirror: recreate the /Game tree. Folder per asset: each ticked asset gets its own folder with its dependencies beside it.")) + EExportLayout ExportLayout = EExportLayout::Flat; + + /** + * Within each asset's folder, sort its dependencies into per-type subfolders. + * + * Only has an effect with the folder-per-asset layout - the other two have no per-asset folder + * to sort inside. Folder names come from the same type presets the Types filter shows, so the + * folders match the words used to filter. + */ + UPROPERTY(config, EditAnywhere, Category = "Export", meta = (EditCondition = "ExportLayout == EExportLayout::FolderPerAsset", ToolTip = "Put textures, materials and meshes into their own subfolders inside each asset's folder.")) + bool bGroupDependenciesByType = false; + + /** + * Package or content-relative prefixes excluded from the sweep. + * + * Defaults cover this project's bought content packs. Roughly 65% of the project's assets are + * third-party, so without this the report is dominated by content nobody audits. + */ + UPROPERTY(config, EditAnywhere, Category = "Filtering", meta = (ToolTip = "Folders to skip entirely. Accepts /Game/... or Content/... form.")) + TArray ExcludedPackagePaths; + + /** Roots to sweep. Empty means /Game. */ + UPROPERTY(config, EditAnywhere, Category = "Filtering", meta = (ToolTip = "Folders to analyse. Leave empty to sweep all of /Game.")) + TArray IncludedPackagePaths; + + /** Read Config/ and Source/ for path literals the Asset Registry cannot see. */ + UPROPERTY(config, EditAnywhere, Category = "Filtering", meta = (ToolTip = "Scan Config/ and Source/ for /Game paths. Without this the project's GameMode and GameInstance report as unused, because only DefaultEngine.ini names them.")) + bool bScanIndirectReferences = true; + + /** Hide __ExternalActors__ / __ExternalObjects__ rows; they are plumbing, not artist-facing. */ + UPROPERTY(config, EditAnywhere, Category = "Filtering", meta = (ToolTip = "Hide One File Per Actor packages from the results. They are still traversed.")) + bool bHideExternalPackages = true; + + static const UAssetUsageAuditSettings* Get(); + + /** Resolved absolute export directory, applying the Saved/AssetUsageAudit fallback. */ + FString GetResolvedExportDirectory() const; + + virtual FName GetCategoryName() const override; +}; diff --git a/Source/AssetUsageAuditEditor/Public/AssetUsageAuditUserSettings.h b/Source/AssetUsageAuditEditor/Public/AssetUsageAuditUserSettings.h new file mode 100644 index 0000000..567488b --- /dev/null +++ b/Source/AssetUsageAuditEditor/Public/AssetUsageAuditUserSettings.h @@ -0,0 +1,75 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" +#include "Engine/DeveloperSettings.h" +#include "AssetUsageAuditUserSettings.generated.h" + +/** + * Per-user panel state, written to Saved/Config/.../EditorPerProjectUserSettings.ini. + * + * Separate from UAssetUsageAuditSettings on purpose. That one is DefaultConfig and lands in + * Config/DefaultEditor.ini, which is version-controlled: exclusion lists and export formats are + * team decisions and should be reviewable. Which types someone happened to tick last Tuesday is + * not, and committing it would make every teammate's panel jump around on sync. + * + * Not shown in Project Settings - GetCategoryName is inherited but the class carries no + * EditAnywhere properties, so there is nothing to render. This is state, not configuration. + */ +UCLASS(config = EditorPerProjectUserSettings) +class ASSETUSAGEAUDITEDITOR_API UAssetUsageAuditUserSettings : public UDeveloperSettings +{ + GENERATED_BODY() + +public: + static UAssetUsageAuditUserSettings* Get(); + + /** Audit scope: level packages to sweep. Empty means all levels. */ + UPROPERTY(config) + TArray LastLevelPackages; + + /** Short class names ticked in the type menu. */ + UPROPERTY(config) + TArray VisibleTypeNames; + + /** Substring filter on a Blueprint's generated class. */ + UPROPERTY(config) + FString GeneratedClassFilter; + + /** + * Verdicts left visible, by name rather than by index. + * + * Names because the enum will gain values: an index saved today would silently mean a different + * verdict after the next one is inserted, and the user would find their filter quietly changed. + */ + UPROPERTY(config) + TArray VisibleVerdictNames; + + /** EAuditViewMode as an integer. Validated on load. */ + UPROPERTY(config) + int32 ViewMode = 0; + + /** EFileExportMode as an integer. Validated on load. */ + UPROPERTY(config) + int32 ExportMode = 0; + + /** EReferenceStrengthFilter as an integer. Validated on load. */ + UPROPERTY(config) + int32 ReferenceStrength = 0; + + /** EAssetUsageProvenance bitmask that a row must carry. */ + UPROPERTY(config) + int32 RequiredProvenance = 0; + + /** Whether the export pulls in referenced assets. */ + UPROPERTY(config) + bool bIncludeDependencies = true; + + /** + * Deliberately absent: the search box. + * + * Restoring it would reopen the panel showing nothing, with the reason sitting in a text field + * the user is not looking at. A filter that hides everything must be something they just typed. + */ +}; diff --git a/Source/AssetUsageAuditTests/AssetUsageAuditTests.Build.cs b/Source/AssetUsageAuditTests/AssetUsageAuditTests.Build.cs new file mode 100644 index 0000000..0bca56d --- /dev/null +++ b/Source/AssetUsageAuditTests/AssetUsageAuditTests.Build.cs @@ -0,0 +1,26 @@ +// NextGenium 2026. Asset Usage Audit. + +using UnrealBuildTool; + +public class AssetUsageAuditTests : ModuleRules +{ + public AssetUsageAuditTests(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicDependencyModuleNames.AddRange(new string[] + { + "Core", + "CoreUObject", + "Engine" + }); + + PrivateDependencyModuleNames.AddRange(new string[] + { + "AssetRegistry", + "AssetUsageAuditCore", + "Json", + "Projects" + }); + } +} diff --git a/Source/AssetUsageAuditTests/AssetUsageAuditTests.cpp b/Source/AssetUsageAuditTests/AssetUsageAuditTests.cpp new file mode 100644 index 0000000..ac22529 --- /dev/null +++ b/Source/AssetUsageAuditTests/AssetUsageAuditTests.cpp @@ -0,0 +1,5 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "Modules/ModuleManager.h" + +IMPLEMENT_MODULE(FDefaultModuleImpl, AssetUsageAuditTests) diff --git a/Source/AssetUsageAuditTests/Private/AssetDependencyClosure.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetDependencyClosure.spec.cpp new file mode 100644 index 0000000..c51f0ab --- /dev/null +++ b/Source/AssetUsageAuditTests/Private/AssetDependencyClosure.spec.cpp @@ -0,0 +1,376 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetDependencyClosure.h" + +#include "AssetUsageAuditTypes.h" +#include "AssetUsagePaths.h" +#include "AssetRegistry/ARFilter.h" +#include "AssetRegistry/IAssetRegistry.h" +#include "Engine/StaticMesh.h" +#include "Misc/AutomationTest.h" + +#if WITH_DEV_AUTOMATION_TESTS + +/** + * The dependency closure behind "export a mesh and get its materials too". + * + * Runs against the real registry, because the thing worth testing is whether the traversal finds + * the edges the engine actually stores - a mock would only prove that a BFS visits nodes. + */ +BEGIN_DEFINE_SPEC(AssetDependencyClosureSpec, + "AssetUsageAudit.DependencyClosure", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + + static IAssetRegistry& ReadyRegistry() + { + IAssetRegistry& Registry = IAssetRegistry::GetChecked(); + if (Registry.IsLoadingAssets()) + { + Registry.WaitForCompletion(); + } + return Registry; + } + + /** + * A /Game static mesh that actually references something, so the closure has work to do. + * + * Picking the first mesh found is not enough: a mesh with no material assigned would make the + * "brings materials along" test pass vacuously. + */ + static FName FindMeshWithDependencies(int32& OutDirectCount) + { + FARFilter Filter; + Filter.ClassPaths.Add(UStaticMesh::StaticClass()->GetClassPathName()); + Filter.PackagePaths.Add(FName(TEXT("/Game"))); + Filter.bRecursivePaths = true; + Filter.bRecursiveClasses = true; + Filter.bIncludeOnlyOnDiskAssets = true; + + IAssetRegistry& Registry = ReadyRegistry(); + + FName Best = NAME_None; + int32 BestCount = 0; + + Registry.EnumerateAssets(Filter, [&Registry, &Best, &BestCount](const FAssetData& AssetData) + { + TArray Dependencies; + Registry.GetDependencies( + AssetData.PackageName, + Dependencies, + AssetUsageAudit::MakeTraversalCategory(), + AssetUsageAudit::MakeTraversalQuery()); + + int32 GameDependencies = 0; + for (FName Dependency : Dependencies) + { + if (Dependency.ToString().StartsWith(TEXT("/Game/"))) + { + ++GameDependencies; + } + } + + if (GameDependencies > BestCount) + { + BestCount = GameDependencies; + Best = AssetData.PackageName; + } + + // Two /Game dependencies is enough to be a meaningful subject; stop hunting. + return BestCount < 2; + }); + + OutDirectCount = BestCount; + return Best; + } + +END_DEFINE_SPEC(AssetDependencyClosureSpec) + +void AssetDependencyClosureSpec::Define() +{ + Describe("Degenerate input", [this]() + { + It("returns nothing for no seeds", [this]() + { + AssetDependencyClosure::FStats Stats; + const TArray Result = AssetDependencyClosure::Gather(ReadyRegistry(), {}, {}, Stats); + + TestEqual(TEXT("empty"), Result.Num(), 0); + TestEqual(TEXT("no seeds counted"), Stats.SeedCount, 0); + }); + + It("drops a malformed seed instead of passing it on", [this]() + { + // A name that is not a long package name has no file and no dependencies. Keeping it + // would push the failure down into the exporter, which would report it as missing. + AssetDependencyClosure::FStats Stats; + const TArray Result = AssetDependencyClosure::Gather( + ReadyRegistry(), { FName(TEXT("NotAPackage")), FName() }, {}, Stats); + + TestEqual(TEXT("nothing kept"), Result.Num(), 0); + }); + + It("keeps a seed that has no dependencies at all", [this]() + { + AssetDependencyClosure::FStats Stats; + const TArray Result = AssetDependencyClosure::Gather( + ReadyRegistry(), { FName(TEXT("/Game/NoSuchAsset_AssetUsageAuditTest")) }, {}, Stats); + + TestEqual(TEXT("the seed survives"), Result.Num(), 1); + TestEqual(TEXT("counted as a seed"), Stats.SeedCount, 1); + }); + + It("de-duplicates a seed listed twice", [this]() + { + const FName Same(TEXT("/Game/NoSuchAsset_AssetUsageAuditTest")); + + AssetDependencyClosure::FStats Stats; + const TArray Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Same, Same, Same }, {}, Stats); + + TestEqual(TEXT("once"), Result.Num(), 1); + }); + }); + + Describe("Walking real dependencies", [this]() + { + It("brings a mesh's materials and textures along", [this]() + { + int32 DirectCount = 0; + const FName Mesh = FindMeshWithDependencies(DirectCount); + + if (Mesh.IsNone()) + { + AddWarning(TEXT("No /Game static mesh with dependencies was found; skipping.")); + return; + } + + AddInfo(FString::Printf(TEXT("Using '%s' with %d direct /Game dependencies."), *Mesh.ToString(), DirectCount)); + + AssetDependencyClosure::FStats Stats; + const TArray Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Mesh }, {}, Stats); + + // This is the whole point of the feature: exporting the seed alone produces a file that + // opens with no material. + TestTrue(TEXT("more than the seed came back"), Result.Num() > 1); + TestEqual(TEXT("the seed is first"), Result[0], Mesh); + TestEqual(TEXT("stats agree with the array"), Stats.TotalCount, Result.Num()); + TestTrue(TEXT("walked at least one hop"), Stats.DeepestHop >= 1); + }); + + It("leaves engine content out by default", [this]() + { + int32 DirectCount = 0; + const FName Mesh = FindMeshWithDependencies(DirectCount); + + if (Mesh.IsNone()) + { + AddWarning(TEXT("No /Game static mesh with dependencies was found; skipping.")); + return; + } + + AssetDependencyClosure::FStats Stats; + const TArray Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Mesh }, {}, Stats); + + // The destination project ships /Engine itself; copying it there is at best redundant + // and at worst overwrites engine files. + for (FName Package : Result) + { + const FString AsString = Package.ToString(); + TestFalse(*FString::Printf(TEXT("no engine package: %s"), *AsString), AsString.StartsWith(TEXT("/Engine/"))); + TestFalse(*FString::Printf(TEXT("no script package: %s"), *AsString), AsString.StartsWith(TEXT("/Script/"))); + } + }); + + It("respects the exclusion list for dependencies but not for seeds", [this]() + { + int32 DirectCount = 0; + const FName Mesh = FindMeshWithDependencies(DirectCount); + + if (Mesh.IsNone()) + { + AddWarning(TEXT("No /Game static mesh with dependencies was found; skipping.")); + return; + } + + // Excluding all of /Game must still return the ticked asset: the user asked for that + // file by name. It is the traversal behind it that the exclusion list bounds. + AssetDependencyClosure::FOptions Options; + Options.ExcludePackagePaths.Add(TEXT("/Game")); + + AssetDependencyClosure::FStats Stats; + const TArray Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Mesh }, Options, Stats); + + TestEqual(TEXT("only the seed survives"), Result.Num(), 1); + TestEqual(TEXT("and it is the seed"), Result[0], Mesh); + TestTrue(TEXT("exclusions were counted"), Stats.SkippedExcluded > 0); + }); + + It("stops at the requested depth", [this]() + { + int32 DirectCount = 0; + const FName Mesh = FindMeshWithDependencies(DirectCount); + + if (Mesh.IsNone()) + { + AddWarning(TEXT("No /Game static mesh with dependencies was found; skipping.")); + return; + } + + AssetDependencyClosure::FOptions Options; + Options.MaxDepth = 1; + + AssetDependencyClosure::FStats Stats; + const TArray Limited = AssetDependencyClosure::Gather(ReadyRegistry(), { Mesh }, Options, Stats); + + TestTrue(TEXT("never went past one hop"), Stats.DeepestHop <= 1); + + AssetDependencyClosure::FStats FullStats; + const TArray Full = AssetDependencyClosure::Gather(ReadyRegistry(), { Mesh }, {}, FullStats); + + TestTrue(TEXT("the limit removed something or the graph was one hop deep"), + Limited.Num() <= Full.Num()); + }); + + It("does not follow a map that an ordinary asset merely mentions", [this]() + { + // The defect this guards against: exporting WP_Main pulled in the whole of L_MainLevel + // through BP_FirstPersonGameMode -> PDA_MenuSystemConfig, 9994 packages belonging to a + // different location. FLevelUsageResolver already refused that crossing; the closure + // did not, so the two disagreed about the same graph. + IAssetRegistry& Registry = ReadyRegistry(); + + FARFilter Filter; + Filter.PackagePaths.Add(FName(TEXT("/Game"))); + Filter.bRecursivePaths = true; + Filter.bIncludeOnlyOnDiskAssets = true; + + const FTopLevelAssetPath WorldClass = UWorld::StaticClass()->GetClassPathName(); + + FName Culprit = NAME_None; + FName ReferencedMap = NAME_None; + + Registry.EnumerateAssets(Filter, [&](const FAssetData& AssetData) + { + // The source must not itself be a level or an external actor package, or the + // crossing would be structural and legitimately followed. + if (AssetData.AssetClassPath == WorldClass + || AssetUsagePaths::IsExternalPackage(AssetData.PackageName)) + { + return true; + } + + TArray Dependencies; + Registry.GetDependencies( + AssetData.PackageName, + Dependencies, + AssetUsageAudit::MakeTraversalCategory(), + AssetUsageAudit::MakeTraversalQuery()); + + for (FName Dependency : Dependencies) + { + TArray InPackage; + Registry.GetAssetsByPackageName(Dependency, InPackage, true); + + for (const FAssetData& Inner : InPackage) + { + if (Inner.AssetClassPath == WorldClass) + { + Culprit = AssetData.PackageName; + ReferencedMap = Dependency; + return false; + } + } + } + + return true; + }); + + if (Culprit.IsNone()) + { + AddWarning(TEXT("No non-level asset referencing a map was found; skipping.")); + return; + } + + AddInfo(FString::Printf(TEXT("'%s' references the map '%s'."), *Culprit.ToString(), *ReferencedMap.ToString())); + + AssetDependencyClosure::FStats Stats; + const TArray Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Culprit }, {}, Stats); + + TestFalse(TEXT("the foreign map is not in the closure"), Result.Contains(ReferencedMap)); + TestTrue(TEXT("and the refusal was counted"), Stats.ForeignLevelsSkipped > 0); + }); + + It("still expands a level that was ticked directly", [this]() + { + // The rule bounds foreign maps, not the seed. Someone who ticks a location is asking + // for its contents - that is the task this tool exists for. + IAssetRegistry& Registry = ReadyRegistry(); + + FARFilter Filter; + Filter.ClassPaths.Add(UWorld::StaticClass()->GetClassPathName()); + Filter.PackagePaths.Add(FName(TEXT("/Game"))); + Filter.bRecursivePaths = true; + Filter.bIncludeOnlyOnDiskAssets = true; + + FName Level = NAME_None; + + Registry.EnumerateAssets(Filter, [&Registry, &Level](const FAssetData& AssetData) + { + TArray Dependencies; + Registry.GetDependencies( + AssetData.PackageName, + Dependencies, + AssetUsageAudit::MakeTraversalCategory(), + AssetUsageAudit::MakeTraversalQuery()); + + for (FName Dependency : Dependencies) + { + if (Dependency.ToString().StartsWith(TEXT("/Game/"))) + { + Level = AssetData.PackageName; + return false; + } + } + + return true; + }); + + if (Level.IsNone()) + { + AddWarning(TEXT("No /Game level with dependencies was found; skipping.")); + return; + } + + AssetDependencyClosure::FStats Stats; + const TArray Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Level }, {}, Stats); + + TestTrue(TEXT("the level brought its content"), Result.Num() > 1); + TestEqual(TEXT("the level is the seed"), Result[0], Level); + }); + + It("stops when the progress callback asks it to", [this]() + { + int32 DirectCount = 0; + const FName Mesh = FindMeshWithDependencies(DirectCount); + + if (Mesh.IsNone()) + { + AddWarning(TEXT("No /Game static mesh with dependencies was found; skipping.")); + return; + } + + AssetDependencyClosure::FOptions Options; + Options.OnProgress = [](int32, int32) { return false; }; + + AssetDependencyClosure::FStats Stats; + const TArray Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Mesh }, Options, Stats); + + TestTrue(TEXT("flagged as stopped"), Stats.bStoppedEarly); + + // A cancelled walk still returns the seeds, so a caller that ignores the flag exports + // what was ticked rather than nothing at all. + TestEqual(TEXT("seeds survive cancellation"), Result.Num(), 1); + }); + }); +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/AssetUsageAuditTests/Private/AssetExchangeExport.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetExchangeExport.spec.cpp new file mode 100644 index 0000000..22fe16d --- /dev/null +++ b/Source/AssetUsageAuditTests/Private/AssetExchangeExport.spec.cpp @@ -0,0 +1,326 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetUsageExporter.h" + +#include "AssetRegistry/ARFilter.h" +#include "AssetRegistry/IAssetRegistry.h" +#include "Engine/StaticMesh.h" +#include "Engine/Texture2D.h" +#include "HAL/FileManager.h" +#include "Misc/AutomationTest.h" +#include "Misc/Paths.h" + +#if WITH_DEV_AUTOMATION_TESTS + +/** + * The second export mode: convert assets to interchange formats instead of copying .uasset files. + * + * Format selection is a pure function over a class path and a map, so it is tested directly. The + * conversion itself needs a loaded UObject and a registered UExporter, so those cases run against + * real project content and warn rather than fail when the content is not there. + */ +BEGIN_DEFINE_SPEC(AssetExchangeExportSpec, + "AssetUsageAudit.ExchangeExport", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + + FString ScratchDir; + + void MakeScratchDir(const TCHAR* Suffix) + { + ScratchDir = FPaths::ConvertRelativePathToFull( + FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("AssetUsageAuditTests"), Suffix)); + + IFileManager::Get().DeleteDirectory(*ScratchDir, false, true); + IFileManager::Get().MakeDirectory(*ScratchDir, true); + } + + void RemoveScratchDir() + { + if (!ScratchDir.IsEmpty()) + { + IFileManager::Get().DeleteDirectory(*ScratchDir, false, true); + } + } + + static IAssetRegistry& ReadyRegistry() + { + IAssetRegistry& Registry = IAssetRegistry::GetChecked(); + if (Registry.IsLoadingAssets()) + { + Registry.WaitForCompletion(); + } + return Registry; + } + + /** First /Game asset of the given class, as an export item. Sorted for reproducibility. */ + static bool FindItemOfClass(UClass* Class, FAssetUsageExporter::FExportItem& OutItem) + { + FARFilter Filter; + Filter.ClassPaths.Add(Class->GetClassPathName()); + Filter.PackagePaths.Add(FName(TEXT("/Game"))); + Filter.bRecursivePaths = true; + Filter.bRecursiveClasses = true; + Filter.bIncludeOnlyOnDiskAssets = true; + + TArray Found; + + ReadyRegistry().EnumerateAssets(Filter, [&Found](const FAssetData& AssetData) + { + Found.Add(AssetData); + return Found.Num() < 64; + }); + + if (Found.IsEmpty()) + { + return false; + } + + Found.Sort([](const FAssetData& A, const FAssetData& B) + { + return A.PackageName.LexicalLess(B.PackageName); + }); + + OutItem.PackageName = Found[0].PackageName; + OutItem.AssetName = Found[0].AssetName; + OutItem.ClassPath = Found[0].AssetClassPath; + return true; + } + + int32 CountFilesInScratch(const TCHAR* Wildcard = TEXT("*.*")) const + { + TArray Found; + IFileManager::Get().FindFilesRecursive(Found, *ScratchDir, Wildcard, true, false); + return Found.Num(); + } + +END_DEFINE_SPEC(AssetExchangeExportSpec) + +void AssetExchangeExportSpec::Define() +{ + Describe("FindFormatForClass", [this]() + { + It("matches a class named directly in the map", [this]() + { + const TMap Map = { { TEXT("StaticMesh"), TEXT("fbx") } }; + + TestEqual(TEXT("direct hit"), + FAssetUsageExporter::FindFormatForClass(FTopLevelAssetPath(TEXT("/Script/Engine"), TEXT("StaticMesh")), Map), + FString(TEXT("fbx"))); + }); + + It("returns nothing for a class with no entry", [this]() + { + const TMap Map = { { TEXT("StaticMesh"), TEXT("fbx") } }; + + TestTrue(TEXT("no format"), + FAssetUsageExporter::FindFormatForClass( + FTopLevelAssetPath(TEXT("/Script/Engine"), TEXT("Blueprint")), Map).IsEmpty()); + }); + + It("returns nothing for an invalid class path rather than guessing", [this]() + { + TestTrue(TEXT("no format"), + FAssetUsageExporter::FindFormatForClass(FTopLevelAssetPath(), FAssetUsageExporter::GetDefaultFormatByClass()).IsEmpty()); + }); + + It("inherits a format from a base class", [this]() + { + // One "Texture" entry has to cover Texture2D, or every texture subclass needs its own + // line in the settings. Requires the class to be findable, which Engine classes are. + const TMap Map = { { TEXT("Texture"), TEXT("png") } }; + + TestEqual(TEXT("Texture2D inherits from Texture"), + FAssetUsageExporter::FindFormatForClass(UTexture2D::StaticClass()->GetClassPathName(), Map), + FString(TEXT("png"))); + }); + + It("prefers the exact entry over an inherited one", [this]() + { + const TMap Map = { + { TEXT("Texture"), TEXT("hdr") }, + { TEXT("Texture2D"), TEXT("png") } + }; + + TestEqual(TEXT("exact wins"), + FAssetUsageExporter::FindFormatForClass(UTexture2D::StaticClass()->GetClassPathName(), Map), + FString(TEXT("png"))); + }); + }); + + Describe("The default format map", [this]() + { + It("covers the asset types artists actually export", [this]() + { + const TMap Defaults = FAssetUsageExporter::GetDefaultFormatByClass(); + + TestEqual(TEXT("static meshes as fbx"), Defaults.FindRef(TEXT("StaticMesh")), FString(TEXT("fbx"))); + TestEqual(TEXT("textures as png"), Defaults.FindRef(TEXT("Texture2D")), FString(TEXT("png"))); + TestEqual(TEXT("sounds as wav"), Defaults.FindRef(TEXT("SoundWave")), FString(TEXT("wav"))); + }); + + It("leaves out Blueprint, which has no interchange format", [this]() + { + // A Blueprint entry would promise a conversion the engine cannot perform. Skipping it + // with a counted reason is honest; producing a .t3d text dump labelled "exported" is not. + const TMap Defaults = FAssetUsageExporter::GetDefaultFormatByClass(); + + TestFalse(TEXT("no Blueprint entry"), Defaults.Contains(TEXT("Blueprint"))); + }); + }); + + Describe("Converting without a usable target", [this]() + { + It("fails cleanly when no target directory is given", [this]() + { + AddExpectedError(TEXT("Conversion aborted: no target directory was given"), EAutomationExpectedErrorFlags::Contains, 1); + + FAssetUsageExporter::FExchangeOptions Options; + + FAssetUsageExporter::FExportItem Item; + Item.PackageName = FName(TEXT("/Game/Whatever")); + Item.AssetName = FName(TEXT("Whatever")); + + const FAssetUsageExporter::FExchangeResult Result = + FAssetUsageExporter::ExportConvertedAssets({ Item }, Options); + + TestFalse(TEXT("not successful"), Result.bSuccess); + TestEqual(TEXT("wrote nothing"), Result.FilesWritten, 0); + }); + + It("counts an asset with no configured format without loading it", [this]() + { + MakeScratchDir(TEXT("NoFormat")); + + FAssetUsageExporter::FExchangeOptions Options; + Options.TargetDirectory = ScratchDir; + + FAssetUsageExporter::FExportItem Item; + Item.PackageName = FName(TEXT("/Game/NoSuchAsset_AssetUsageAuditTest")); + Item.AssetName = FName(TEXT("NoSuchAsset_AssetUsageAuditTest")); + Item.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine"), TEXT("Blueprint")); + + const FAssetUsageExporter::FExchangeResult Result = + FAssetUsageExporter::ExportConvertedAssets({ Item }, Options); + + TestEqual(TEXT("skipped for want of a format"), Result.SkippedNoFormat, 1); + TestEqual(TEXT("never attempted a load"), Result.SkippedNotLoaded, 0); + TestEqual(TEXT("no errors"), Result.Errors.Num(), 0); + + RemoveScratchDir(); + }); + + It("stops when the progress callback asks it to", [this]() + { + MakeScratchDir(TEXT("ConvertCancel")); + + FAssetUsageExporter::FExchangeOptions Options; + Options.TargetDirectory = ScratchDir; + + int32 Calls = 0; + Options.OnProgress = [&Calls](int32, int32) { ++Calls; return false; }; + + FAssetUsageExporter::FExportItem Item; + Item.PackageName = FName(TEXT("/Game/A_AssetUsageAuditTest")); + Item.AssetName = FName(TEXT("A_AssetUsageAuditTest")); + + const FAssetUsageExporter::FExchangeResult Result = + FAssetUsageExporter::ExportConvertedAssets({ Item, Item }, Options); + + TestTrue(TEXT("flagged as cancelled"), Result.bCancelled); + TestFalse(TEXT("not reported successful"), Result.bSuccess); + TestEqual(TEXT("stopped at the first item"), Calls, 1); + + RemoveScratchDir(); + }); + }); + + Describe("Converting real project content", [this]() + { + AfterEach([this]() { RemoveScratchDir(); }); + + It("writes a PNG for a texture", [this]() + { + FAssetUsageExporter::FExportItem Item; + if (!FindItemOfClass(UTexture2D::StaticClass(), Item)) + { + AddWarning(TEXT("No Texture2D found under /Game; skipping.")); + return; + } + + AddInfo(FString::Printf(TEXT("Converting '%s'."), *Item.PackageName.ToString())); + + MakeScratchDir(TEXT("ConvertTexture")); + + FAssetUsageExporter::FExchangeOptions Options; + Options.TargetDirectory = ScratchDir; + + const FAssetUsageExporter::FExchangeResult Result = + FAssetUsageExporter::ExportConvertedAssets({ Item }, Options); + + TestTrue(TEXT("succeeded"), Result.bSuccess); + TestEqual(TEXT("one file written"), Result.FilesWritten, 1); + + const FString Expected = FPaths::Combine(ScratchDir, Item.AssetName.ToString() + TEXT(".png")); + TestTrue(TEXT("the png exists"), IFileManager::Get().FileExists(*Expected)); + + // An exporter can create the file and write nothing into it; a zero-byte PNG would pass + // an existence check and be useless to whoever opens the folder. + TestTrue(TEXT("the png is not empty"), IFileManager::Get().FileSize(*Expected) > 0); + }); + + It("writes an FBX for a static mesh", [this]() + { + FAssetUsageExporter::FExportItem Item; + if (!FindItemOfClass(UStaticMesh::StaticClass(), Item)) + { + AddWarning(TEXT("No StaticMesh found under /Game; skipping.")); + return; + } + + AddInfo(FString::Printf(TEXT("Converting '%s'."), *Item.PackageName.ToString())); + + MakeScratchDir(TEXT("ConvertMesh")); + + FAssetUsageExporter::FExchangeOptions Options; + Options.TargetDirectory = ScratchDir; + + const FAssetUsageExporter::FExchangeResult Result = + FAssetUsageExporter::ExportConvertedAssets({ Item }, Options); + + TestTrue(TEXT("succeeded"), Result.bSuccess); + TestEqual(TEXT("one file written"), Result.FilesWritten, 1); + + const FString Expected = FPaths::Combine(ScratchDir, Item.AssetName.ToString() + TEXT(".fbx")); + TestTrue(TEXT("the fbx exists"), IFileManager::Get().FileExists(*Expected)); + TestTrue(TEXT("the fbx is not empty"), IFileManager::Get().FileSize(*Expected) > 0); + }); + + It("indexes the second conversion of the same asset", [this]() + { + FAssetUsageExporter::FExportItem Item; + if (!FindItemOfClass(UTexture2D::StaticClass(), Item)) + { + AddWarning(TEXT("No Texture2D found under /Game; skipping.")); + return; + } + + MakeScratchDir(TEXT("ConvertIndex")); + + FAssetUsageExporter::FExchangeOptions Options; + Options.TargetDirectory = ScratchDir; + Options.CollisionPolicy = EExportCollisionPolicy::Index; + + FAssetUsageExporter::ExportConvertedAssets({ Item }, Options); + const FAssetUsageExporter::FExchangeResult Second = + FAssetUsageExporter::ExportConvertedAssets({ Item }, Options); + + TestEqual(TEXT("second run renamed"), Second.FilesRenamed, 1); + TestEqual(TEXT("two files on disk"), CountFilesInScratch(TEXT("*.png")), 2); + + const FString Indexed = FPaths::Combine(ScratchDir, Item.AssetName.ToString() + TEXT("_1.png")); + TestTrue(TEXT("_1 present"), IFileManager::Get().FileExists(*Indexed)); + }); + }); +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/AssetUsageAuditTests/Private/AssetExportLayout.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetExportLayout.spec.cpp new file mode 100644 index 0000000..0b9bd0e --- /dev/null +++ b/Source/AssetUsageAuditTests/Private/AssetExportLayout.spec.cpp @@ -0,0 +1,357 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetExportLayout.h" + +#include "AssetDependencyClosure.h" +#include "AssetUsageExporter.h" +#include "AssetRegistry/ARFilter.h" +#include "AssetRegistry/IAssetRegistry.h" +#include "Engine/StaticMesh.h" +#include "Engine/Texture2D.h" +#include "HAL/FileManager.h" +#include "Misc/AutomationTest.h" +#include "Misc/Paths.h" + +#if WITH_DEV_AUTOMATION_TESTS + +/** + * Where exported files land. + * + * The grouping logic is pure enough to test on invented package names; the type-folder lookup and + * the end-to-end copy need real content and say so when it is missing. + */ +BEGIN_DEFINE_SPEC(AssetExportLayoutSpec, + "AssetUsageAudit.ExportLayout", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + + FString ScratchDir; + + void MakeScratchDir(const TCHAR* Suffix) + { + ScratchDir = FPaths::ConvertRelativePathToFull( + FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("AssetUsageAuditTests"), Suffix)); + + IFileManager::Get().DeleteDirectory(*ScratchDir, false, true); + IFileManager::Get().MakeDirectory(*ScratchDir, true); + } + + void RemoveScratchDir() + { + if (!ScratchDir.IsEmpty()) + { + IFileManager::Get().DeleteDirectory(*ScratchDir, false, true); + } + } + + static IAssetRegistry& ReadyRegistry() + { + IAssetRegistry& Registry = IAssetRegistry::GetChecked(); + if (Registry.IsLoadingAssets()) + { + Registry.WaitForCompletion(); + } + return Registry; + } + + /** A closure with a made-up seed and dependencies, for testing grouping alone. */ + static AssetDependencyClosure::FSeedClosure MakeClosure(const TCHAR* Seed, std::initializer_list Dependencies) + { + AssetDependencyClosure::FSeedClosure Closure; + Closure.Seed = FName(Seed); + Closure.Packages.Add(Closure.Seed); + + for (const TCHAR* Dependency : Dependencies) + { + Closure.Packages.Add(FName(Dependency)); + } + + return Closure; + } + + /** First /Game asset of a class, or NAME_None. */ + static FName FindPackageOfClass(UClass* Class) + { + FARFilter Filter; + Filter.ClassPaths.Add(Class->GetClassPathName()); + Filter.PackagePaths.Add(FName(TEXT("/Game"))); + Filter.bRecursivePaths = true; + Filter.bRecursiveClasses = true; + Filter.bIncludeOnlyOnDiskAssets = true; + + TArray Found; + + ReadyRegistry().EnumerateAssets(Filter, [&Found](const FAssetData& AssetData) + { + Found.Add(AssetData.PackageName); + return Found.Num() < 64; + }); + + if (Found.IsEmpty()) + { + return NAME_None; + } + + Found.Sort(FNameLexicalLess()); + return Found[0]; + } + +END_DEFINE_SPEC(AssetExportLayoutSpec) + +void AssetExportLayoutSpec::Define() +{ + Describe("Flat and mirrored layouts", [this]() + { + It("collapses every closure into a single unnamed group", [this]() + { + const TArray Closures = { + MakeClosure(TEXT("/Game/A"), { TEXT("/Game/Shared") }), + MakeClosure(TEXT("/Game/B"), { TEXT("/Game/Shared") }) + }; + + AssetExportLayout::FOptions Options; + Options.Layout = EExportLayout::Flat; + + AssetExportLayout::FStats Stats; + const TArray Groups = + AssetExportLayout::BuildGroups(ReadyRegistry(), Closures, Options, Stats); + + TestEqual(TEXT("one group"), Groups.Num(), 1); + TestTrue(TEXT("writing into the target itself"), Groups[0].RelativeDir.IsEmpty()); + + // The shared dependency appears once: a flat folder cannot hold it twice, and pretending + // otherwise would make the file count disagree with what gets written. + TestEqual(TEXT("three distinct packages"), Groups[0].Packages.Num(), 3); + TestEqual(TEXT("stats agree"), Stats.FilePlacements, 3); + TestEqual(TEXT("distinct count matches"), Stats.DistinctPackages, 3); + }); + + It("treats MirrorTree the same at this level", [this]() + { + // Mirroring is the exporter's job, applied per file inside whichever folder a group + // names. The layout planner has nothing extra to do for it. + const TArray Closures = { + MakeClosure(TEXT("/Game/A"), { TEXT("/Game/Shared") }) + }; + + AssetExportLayout::FOptions Options; + Options.Layout = EExportLayout::MirrorTree; + + AssetExportLayout::FStats Stats; + const TArray Groups = + AssetExportLayout::BuildGroups(ReadyRegistry(), Closures, Options, Stats); + + TestEqual(TEXT("one group"), Groups.Num(), 1); + TestTrue(TEXT("no subfolder from the planner"), Groups[0].RelativeDir.IsEmpty()); + }); + }); + + Describe("Folder per asset", [this]() + { + It("gives each seed its own folder named after the asset", [this]() + { + const TArray Closures = { + MakeClosure(TEXT("/Game/Art/SM_Rock"), {}), + MakeClosure(TEXT("/Game/Art/SM_Tree"), {}) + }; + + AssetExportLayout::FOptions Options; + Options.Layout = EExportLayout::FolderPerAsset; + + AssetExportLayout::FStats Stats; + const TArray Groups = + AssetExportLayout::BuildGroups(ReadyRegistry(), Closures, Options, Stats); + + TestEqual(TEXT("one group per seed"), Groups.Num(), 2); + TestEqual(TEXT("named after the asset, not the path"), Groups[0].RelativeDir, FString(TEXT("SM_Rock"))); + TestEqual(TEXT("second folder"), Groups[1].RelativeDir, FString(TEXT("SM_Tree"))); + }); + + It("copies a shared dependency into every folder that needs it", [this]() + { + // The defining property of this layout: each folder must be self-contained, so a texture + // used by two meshes is written twice. Deduplicating it would leave the second folder + // opening with missing content. + const TArray Closures = { + MakeClosure(TEXT("/Game/Art/SM_Rock"), { TEXT("/Game/Art/T_Shared") }), + MakeClosure(TEXT("/Game/Art/SM_Tree"), { TEXT("/Game/Art/T_Shared") }) + }; + + AssetExportLayout::FOptions Options; + Options.Layout = EExportLayout::FolderPerAsset; + + AssetExportLayout::FStats Stats; + const TArray Groups = + AssetExportLayout::BuildGroups(ReadyRegistry(), Closures, Options, Stats); + + int32 SharedPlacements = 0; + for (const AssetExportLayout::FGroup& Group : Groups) + { + SharedPlacements += Group.Packages.Contains(FName(TEXT("/Game/Art/T_Shared"))) ? 1 : 0; + } + + TestEqual(TEXT("the texture lands in both folders"), SharedPlacements, 2); + + // Three distinct packages, four files written. This gap is what the export dialog has + // to show, and the stats have to keep the two numbers apart to make that possible. + TestEqual(TEXT("distinct packages"), Stats.DistinctPackages, 3); + TestEqual(TEXT("file placements"), Stats.FilePlacements, 4); + }); + + It("makes folder names unique when two assets share a short name", [this]() + { + // /Game/A/SM_Rock and /Game/B/SM_Rock are different assets with the same name. Without + // a suffix they would share one folder and interleave their dependencies. + const TArray Closures = { + MakeClosure(TEXT("/Game/A/SM_Rock"), {}), + MakeClosure(TEXT("/Game/B/SM_Rock"), {}) + }; + + AssetExportLayout::FOptions Options; + Options.Layout = EExportLayout::FolderPerAsset; + + AssetExportLayout::FStats Stats; + const TArray Groups = + AssetExportLayout::BuildGroups(ReadyRegistry(), Closures, Options, Stats); + + TestEqual(TEXT("two groups"), Groups.Num(), 2); + TestNotEqual(TEXT("folders differ"), Groups[0].RelativeDir, Groups[1].RelativeDir); + TestEqual(TEXT("the rename was counted"), Stats.RenamedFolders, 1); + }); + + It("keeps the seed at the root of its own folder when grouping by type", [this]() + { + const FName MeshPackage = FindPackageOfClass(UStaticMesh::StaticClass()); + if (MeshPackage.IsNone()) + { + AddWarning(TEXT("No /Game StaticMesh found; skipping.")); + return; + } + + AssetDependencyClosure::FSeedClosure Closure; + Closure.Seed = MeshPackage; + Closure.Packages.Add(MeshPackage); + + AssetExportLayout::FOptions Options; + Options.Layout = EExportLayout::FolderPerAsset; + Options.bGroupDependenciesByType = true; + + AssetExportLayout::FStats Stats; + const TArray Groups = + AssetExportLayout::BuildGroups(ReadyRegistry(), Closure.Packages.IsEmpty() + ? TArray() + : TArray{ Closure }, + Options, Stats); + + TestEqual(TEXT("one group"), Groups.Num(), 1); + + // The seed is the subject of its folder. Filing it under StaticMesh/ beside its own + // dependencies would bury the thing the folder is named after. + const FString Expected = FPackageName::GetShortName(MeshPackage.ToString()); + TestEqual(TEXT("seed sits at the folder root"), Groups[0].RelativeDir, Expected); + }); + }); + + Describe("Type folders", [this]() + { + It("names folders after the type presets, not invented labels", [this]() + { + const TMap FolderByClass = AssetExportLayout::BuildTypeFolderMap(ReadyRegistry()); + + const FString* TextureFolder = FolderByClass.Find(UTexture2D::StaticClass()->GetClassPathName()); + + if (!TextureFolder) + { + AddWarning(TEXT("Texture2D missing from the type folder map; skipping.")); + return; + } + + // The Types filter in the panel shows "Texture"; the folder a user gets must match the + // word they filtered by, or the two halves of the tool disagree. + TestEqual(TEXT("Texture2D lands under Texture"), *TextureFolder, FString(TEXT("Texture"))); + }); + + It("returns Other rather than an empty name for an unmapped package", [this]() + { + // An empty folder name would silently write into the parent folder and look like the + // grouping had failed rather than like a type nobody mapped. + const TMap FolderByClass = AssetExportLayout::BuildTypeFolderMap(ReadyRegistry()); + + const FString Folder = AssetExportLayout::TypeFolderForPackage( + ReadyRegistry(), FName(TEXT("/Game/NoSuchPackage_AssetUsageAuditTest")), FolderByClass); + + TestEqual(TEXT("falls back to Other"), Folder, FString(TEXT("Other"))); + }); + }); + + Describe("Groups reaching the exporter", [this]() + { + AfterEach([this]() { RemoveScratchDir(); }); + + It("writes each group into its own subfolder", [this]() + { + const FName Package = FindPackageOfClass(UTexture2D::StaticClass()); + if (Package.IsNone()) + { + AddWarning(TEXT("No /Game Texture2D found; skipping.")); + return; + } + + MakeScratchDir(TEXT("LayoutGroups")); + + // The same package in two groups: it must be written twice, once per folder, and + // neither copy renamed - they do not collide because they are in different folders. + FAssetUsageExporter::FExportGroup First; + First.RelativeDir = TEXT("FolderOne"); + First.Packages.Add(Package); + + FAssetUsageExporter::FExportGroup Second; + Second.RelativeDir = TEXT("FolderTwo"); + Second.Packages.Add(Package); + + FAssetUsageExporter::FOptions Options; + Options.TargetDirectory = ScratchDir; + Options.bIncludeExternalPackages = false; + + const FAssetUsageExporter::FResult Result = + FAssetUsageExporter::ExportPackageGroups({ First, Second }, Options); + + TestTrue(TEXT("succeeded"), Result.bSuccess); + TestEqual(TEXT("copied once per group"), Result.FilesCopied, 2); + TestEqual(TEXT("nothing renamed"), Result.FilesRenamed, 0); + + const FString FileName = FPaths::GetCleanFilename(FAssetUsageExporter::ResolvePackageFilePath(Package)); + + TestTrue(TEXT("first folder"), IFileManager::Get().FileExists(*FPaths::Combine(ScratchDir, TEXT("FolderOne"), FileName))); + TestTrue(TEXT("second folder"), IFileManager::Get().FileExists(*FPaths::Combine(ScratchDir, TEXT("FolderTwo"), FileName))); + }); + + It("still writes into the target when a group has no subfolder", [this]() + { + const FName Package = FindPackageOfClass(UTexture2D::StaticClass()); + if (Package.IsNone()) + { + AddWarning(TEXT("No /Game Texture2D found; skipping.")); + return; + } + + MakeScratchDir(TEXT("LayoutRoot")); + + FAssetUsageExporter::FExportGroup Group; + Group.Packages.Add(Package); + + FAssetUsageExporter::FOptions Options; + Options.TargetDirectory = ScratchDir; + Options.bIncludeExternalPackages = false; + + const FAssetUsageExporter::FResult Result = + FAssetUsageExporter::ExportPackageGroups({ Group }, Options); + + TestEqual(TEXT("one file"), Result.FilesCopied, 1); + + const FString FileName = FPaths::GetCleanFilename(FAssetUsageExporter::ResolvePackageFilePath(Package)); + TestTrue(TEXT("straight into the target"), IFileManager::Get().FileExists(*FPaths::Combine(ScratchDir, FileName))); + }); + }); +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/AssetUsageAuditTests/Private/AssetExportManifest.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetExportManifest.spec.cpp new file mode 100644 index 0000000..9b4a6c4 --- /dev/null +++ b/Source/AssetUsageAuditTests/Private/AssetExportManifest.spec.cpp @@ -0,0 +1,266 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetExportManifest.h" + +#include "AssetRegistry/IAssetRegistry.h" +#include "Dom/JsonObject.h" +#include "HAL/FileManager.h" +#include "Misc/AutomationTest.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonSerializer.h" + +#if WITH_DEV_AUTOMATION_TESTS + +BEGIN_DEFINE_SPEC(AssetExportManifestSpec, + "AssetUsageAudit.ExportManifest", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + + FString ScratchDir; + + void MakeScratchDir(const TCHAR* Suffix) + { + ScratchDir = FPaths::ConvertRelativePathToFull( + FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("AssetUsageAuditTests"), Suffix)); + + IFileManager::Get().DeleteDirectory(*ScratchDir, false, true); + IFileManager::Get().MakeDirectory(*ScratchDir, true); + } + + void RemoveScratchDir() + { + if (!ScratchDir.IsEmpty()) + { + IFileManager::Get().DeleteDirectory(*ScratchDir, false, true); + } + } + + static IAssetRegistry& ReadyRegistry() + { + IAssetRegistry& Registry = IAssetRegistry::GetChecked(); + if (Registry.IsLoadingAssets()) + { + Registry.WaitForCompletion(); + } + return Registry; + } + + /** Parse the manifest the writer just produced. Null when it is missing or malformed. */ + TSharedPtr ReadManifest() const + { + FString Text; + if (!FFileHelper::LoadFileToString(Text, *FPaths::Combine(ScratchDir, AssetExportManifest::FileName))) + { + return nullptr; + } + + TSharedPtr Root; + const TSharedRef> Reader = TJsonReaderFactory<>::Create(Text); + + return FJsonSerializer::Deserialize(Reader, Root) ? Root : nullptr; + } + +END_DEFINE_SPEC(AssetExportManifestSpec) + +void AssetExportManifestSpec::Define() +{ + Describe("Refusing to write a misleading manifest", [this]() + { + AfterEach([this]() { RemoveScratchDir(); }); + + It("fails when the exporter recorded nothing", [this]() + { + // The usual cause is a caller that forgot bRecordWrittenFiles. An empty manifest beside + // a folder full of assets would be believed by whatever reads it next. + MakeScratchDir(TEXT("ManifestEmpty")); + + AddExpectedError(TEXT("the exporter recorded no files"), EAutomationExpectedErrorFlags::Contains, 1); + + AssetExportManifest::FOptions Options; + Options.TargetDirectory = ScratchDir; + + const AssetExportManifest::FResult Result = + AssetExportManifest::Write(ReadyRegistry(), {}, Options); + + TestFalse(TEXT("not successful"), Result.bSuccess); + TestTrue(TEXT("says why"), !Result.ErrorMessage.IsEmpty()); + TestFalse(TEXT("wrote no file"), IFileManager::Get().FileExists( + *FPaths::Combine(ScratchDir, AssetExportManifest::FileName))); + }); + + It("fails when no target directory is given", [this]() + { + AddExpectedError(TEXT("no target directory"), EAutomationExpectedErrorFlags::Contains, 1); + + AssetExportManifest::FOptions Options; + + TArray Written; + Written.AddDefaulted_GetRef().PackageName = FName(TEXT("/Game/Whatever")); + + const AssetExportManifest::FResult Result = + AssetExportManifest::Write(ReadyRegistry(), Written, Options); + + TestFalse(TEXT("not successful"), Result.bSuccess); + }); + + It("refuses under the Migrate layout", [this]() + { + // Migrate keeps package paths intact, so there is nothing to restore. A manifest there + // would imply the references had been broken. + MakeScratchDir(TEXT("ManifestMigrate")); + + AssetExportManifest::FOptions Options; + Options.TargetDirectory = ScratchDir; + Options.Layout = EExportLayout::Migrate; + + TArray Written; + FAssetUsageExporter::FWrittenFile& Entry = Written.AddDefaulted_GetRef(); + Entry.PackageName = FName(TEXT("/Game/Whatever")); + Entry.RelativePath = TEXT("Whatever.uasset"); + + const AssetExportManifest::FResult Result = + AssetExportManifest::Write(ReadyRegistry(), Written, Options); + + TestFalse(TEXT("not successful"), Result.bSuccess); + TestFalse(TEXT("wrote no file"), IFileManager::Get().FileExists( + *FPaths::Combine(ScratchDir, AssetExportManifest::FileName))); + }); + }); + + Describe("The manifest it writes", [this]() + { + AfterEach([this]() { RemoveScratchDir(); }); + + It("is valid JSON carrying the schema version and the layout", [this]() + { + MakeScratchDir(TEXT("ManifestBasic")); + + AssetExportManifest::FOptions Options; + Options.TargetDirectory = ScratchDir; + Options.Layout = EExportLayout::FolderPerAsset; + Options.CollisionPolicy = EExportCollisionPolicy::Index; + + TArray Written; + FAssetUsageExporter::FWrittenFile& Entry = Written.AddDefaulted_GetRef(); + Entry.PackageName = FName(TEXT("/Game/SomeAsset_AssetUsageAuditTest")); + Entry.RelativePath = TEXT("SomeAsset/SomeAsset.uasset"); + + const AssetExportManifest::FResult Result = + AssetExportManifest::Write(ReadyRegistry(), Written, Options); + + TestTrue(TEXT("successful"), Result.bSuccess); + + const TSharedPtr Root = ReadManifest(); + TestTrue(TEXT("parses as JSON"), Root.IsValid()); + + if (!Root.IsValid()) + { + return; + } + + TestEqual(TEXT("schema version"), + static_cast(Root->GetNumberField(TEXT("schemaVersion"))), + AssetExportManifest::SchemaVersion); + + TestEqual(TEXT("records the layout"), Root->GetStringField(TEXT("layout")), FString(TEXT("FolderPerAsset"))); + TestEqual(TEXT("records the naming policy"), Root->GetStringField(TEXT("collisionPolicy")), FString(TEXT("Index"))); + TestTrue(TEXT("has an assets array"), Root->HasField(TEXT("assets"))); + }); + + It("records the path that was actually written, not the intended one", [this]() + { + // The case the manifest exists for: the collision policy renamed the file, so a manifest + // built from the package name alone would point at a file that is not there. + MakeScratchDir(TEXT("ManifestRenamed")); + + AssetExportManifest::FOptions Options; + Options.TargetDirectory = ScratchDir; + + TArray Written; + FAssetUsageExporter::FWrittenFile& Entry = Written.AddDefaulted_GetRef(); + Entry.PackageName = FName(TEXT("/Game/SM_Rock_AssetUsageAuditTest")); + Entry.RelativePath = TEXT("SM_Rock_AssetUsageAuditTest_2.uasset"); + + AssetExportManifest::Write(ReadyRegistry(), Written, Options); + + const TSharedPtr Root = ReadManifest(); + TestTrue(TEXT("parses"), Root.IsValid()); + + if (!Root.IsValid()) + { + return; + } + + const TArray>* Assets = nullptr; + TestTrue(TEXT("has assets"), Root->TryGetArrayField(TEXT("assets"), Assets)); + + if (!Assets || Assets->Num() != 1) + { + AddError(TEXT("expected exactly one asset entry")); + return; + } + + const TSharedPtr First = (*Assets)[0]->AsObject(); + TestEqual(TEXT("original package name"), First->GetStringField(TEXT("package")), + FString(TEXT("/Game/SM_Rock_AssetUsageAuditTest"))); + TestEqual(TEXT("the renamed file"), First->GetStringField(TEXT("file")), + FString(TEXT("SM_Rock_AssetUsageAuditTest_2.uasset"))); + }); + + It("separates the assets that were ticked from those pulled in", [this]() + { + // "Restore what I exported" and "restore everything in this folder" are different + // requests, and only the manifest can tell them apart after the fact. + MakeScratchDir(TEXT("ManifestSeeds")); + + const FName SeedPackage(TEXT("/Game/Seed_AssetUsageAuditTest")); + const FName DependencyPackage(TEXT("/Game/Dependency_AssetUsageAuditTest")); + + AssetExportManifest::FOptions Options; + Options.TargetDirectory = ScratchDir; + Options.SeedPackages = { SeedPackage }; + + TArray Written; + + FAssetUsageExporter::FWrittenFile& SeedEntry = Written.AddDefaulted_GetRef(); + SeedEntry.PackageName = SeedPackage; + SeedEntry.RelativePath = TEXT("Seed.uasset"); + + FAssetUsageExporter::FWrittenFile& DependencyEntry = Written.AddDefaulted_GetRef(); + DependencyEntry.PackageName = DependencyPackage; + DependencyEntry.RelativePath = TEXT("Dependency.uasset"); + + AssetExportManifest::Write(ReadyRegistry(), Written, Options); + + const TSharedPtr Root = ReadManifest(); + if (!Root.IsValid()) + { + AddError(TEXT("manifest did not parse")); + return; + } + + const TArray>* Assets = nullptr; + Root->TryGetArrayField(TEXT("assets"), Assets); + + if (!Assets || Assets->Num() != 2) + { + AddError(TEXT("expected two asset entries")); + return; + } + + int32 SeedCount = 0; + for (const TSharedPtr& Value : *Assets) + { + if (Value->AsObject()->GetBoolField(TEXT("seed"))) + { + ++SeedCount; + } + } + + TestEqual(TEXT("exactly one entry is a seed"), SeedCount, 1); + }); + }); +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/AssetUsageAuditTests/Private/AssetExportNaming.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetExportNaming.spec.cpp new file mode 100644 index 0000000..06cdbff --- /dev/null +++ b/Source/AssetUsageAuditTests/Private/AssetExportNaming.spec.cpp @@ -0,0 +1,289 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetExportNaming.h" +#include "AssetUsageAuditTypes.h" +#include "Misc/AutomationTest.h" + +#if WITH_DEV_AUTOMATION_TESTS + +namespace +{ + /** Builds an Exists predicate over a fixed set of filenames, so no filesystem is involved. */ + struct FFakeFolder + { + TSet Files; + + explicit FFakeFolder(std::initializer_list InFiles) + { + for (const TCHAR* File : InFiles) + { + Files.Add(FString(File)); + } + } + + bool Contains(const FString& FileName) const + { + return Files.Contains(FileName); + } + }; +} + +BEGIN_DEFINE_SPEC(AssetExportNamingSpec, + "AssetUsageAudit.ExportNaming", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) +END_DEFINE_SPEC(AssetExportNamingSpec) + +void AssetExportNamingSpec::Define() +{ + Describe("SplitTrailingIndex", [this]() + { + It("reports no index for a plain name", [this]() + { + FString Stem; + int32 Index = 0; + int32 PadWidth = 0; + AssetExportNaming::SplitTrailingIndex(TEXT("SM_Rock"), Stem, Index, PadWidth); + + TestEqual(TEXT("Stem"), Stem, FString(TEXT("SM_Rock"))); + TestEqual(TEXT("Index"), Index, (int32)INDEX_NONE); + TestEqual(TEXT("PadWidth"), PadWidth, 0); + }); + + It("splits a single-digit trailing index", [this]() + { + FString Stem; + int32 Index = 0; + int32 PadWidth = 0; + AssetExportNaming::SplitTrailingIndex(TEXT("SM_Rock_7"), Stem, Index, PadWidth); + + TestEqual(TEXT("Stem"), Stem, FString(TEXT("SM_Rock"))); + TestEqual(TEXT("Index"), Index, 7); + TestEqual(TEXT("PadWidth"), PadWidth, 1); + }); + + It("preserves zero padding width", [this]() + { + FString Stem; + int32 Index = 0; + int32 PadWidth = 0; + AssetExportNaming::SplitTrailingIndex(TEXT("SM_Rock_007"), Stem, Index, PadWidth); + + TestEqual(TEXT("Stem"), Stem, FString(TEXT("SM_Rock"))); + TestEqual(TEXT("Index"), Index, 7); + TestEqual(TEXT("PadWidth"), PadWidth, 3); + }); + + It("treats a non-numeric suffix as part of the name", [this]() + { + // The trap this whole function exists for: _v3 must not be read as an index, + // and the _02 in the middle must not be touched. + FString Stem; + int32 Index = 0; + int32 PadWidth = 0; + AssetExportNaming::SplitTrailingIndex(TEXT("SM_Rock_02_v3"), Stem, Index, PadWidth); + + TestEqual(TEXT("Stem"), Stem, FString(TEXT("SM_Rock_02_v3"))); + TestEqual(TEXT("Index"), Index, (int32)INDEX_NONE); + }); + + It("splits a mid-name numeric group only when it is trailing", [this]() + { + FString Stem; + int32 Index = 0; + int32 PadWidth = 0; + AssetExportNaming::SplitTrailingIndex(TEXT("SM_Rock_02"), Stem, Index, PadWidth); + + TestEqual(TEXT("Stem"), Stem, FString(TEXT("SM_Rock"))); + TestEqual(TEXT("Index"), Index, 2); + TestEqual(TEXT("PadWidth"), PadWidth, 2); + }); + + It("treats an empty suffix as no index", [this]() + { + FString Stem; + int32 Index = 0; + int32 PadWidth = 0; + AssetExportNaming::SplitTrailingIndex(TEXT("SM_Rock_"), Stem, Index, PadWidth); + + TestEqual(TEXT("Stem"), Stem, FString(TEXT("SM_Rock_"))); + TestEqual(TEXT("Index"), Index, (int32)INDEX_NONE); + }); + + It("does not treat a leading underscore as a separator", [this]() + { + FString Stem; + int32 Index = 0; + int32 PadWidth = 0; + AssetExportNaming::SplitTrailingIndex(TEXT("_1"), Stem, Index, PadWidth); + + TestEqual(TEXT("Stem"), Stem, FString(TEXT("_1"))); + TestEqual(TEXT("Index"), Index, (int32)INDEX_NONE); + }); + }); + + Describe("ComposeIndexedName", [this]() + { + It("returns the bare stem for INDEX_NONE", [this]() + { + TestEqual(TEXT("Composed"), AssetExportNaming::ComposeIndexedName(TEXT("Foo"), INDEX_NONE, 0), FString(TEXT("Foo"))); + }); + + It("appends an unpadded index", [this]() + { + TestEqual(TEXT("Composed"), AssetExportNaming::ComposeIndexedName(TEXT("Foo"), 1, 1), FString(TEXT("Foo_1"))); + }); + + It("restores zero padding", [this]() + { + TestEqual(TEXT("Composed"), AssetExportNaming::ComposeIndexedName(TEXT("Foo"), 8, 3), FString(TEXT("Foo_008"))); + }); + + It("widens rather than truncates when the number outgrows the padding", [this]() + { + TestEqual(TEXT("Composed"), AssetExportNaming::ComposeIndexedName(TEXT("Foo"), 100, 2), FString(TEXT("Foo_100"))); + }); + }); + + Describe("ResolveCollision under Overwrite", [this]() + { + It("returns the desired name even when it is taken", [this]() + { + const FFakeFolder Folder({ TEXT("SM_Rock.uasset") }); + const FString Result = AssetExportNaming::ResolveCollision( + TEXT("SM_Rock.uasset"), + EExportCollisionPolicy::Overwrite, + [&Folder](const FString& Name) { return Folder.Contains(Name); }); + + TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock.uasset"))); + }); + }); + + Describe("ResolveCollision under Index", [this]() + { + It("returns the desired name when the folder is empty", [this]() + { + const FFakeFolder Folder({}); + const FString Result = AssetExportNaming::ResolveCollision( + TEXT("SM_Rock.uasset"), + EExportCollisionPolicy::Index, + [&Folder](const FString& Name) { return Folder.Contains(Name); }); + + TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock.uasset"))); + }); + + It("appends _1 on the first collision", [this]() + { + const FFakeFolder Folder({ TEXT("SM_Rock.uasset") }); + const FString Result = AssetExportNaming::ResolveCollision( + TEXT("SM_Rock.uasset"), + EExportCollisionPolicy::Index, + [&Folder](const FString& Name) { return Folder.Contains(Name); }); + + TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_1.uasset"))); + }); + + It("advances to _2 when _1 is also taken", [this]() + { + const FFakeFolder Folder({ TEXT("SM_Rock.uasset"), TEXT("SM_Rock_1.uasset") }); + const FString Result = AssetExportNaming::ResolveCollision( + TEXT("SM_Rock.uasset"), + EExportCollisionPolicy::Index, + [&Folder](const FString& Name) { return Folder.Contains(Name); }); + + TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_2.uasset"))); + }); + + It("increments an existing index instead of appending a second one", [this]() + { + // The explicit requirement: Foo_7 must become Foo_8, never Foo_7_1. + const FFakeFolder Folder({ TEXT("SM_Rock_7.uasset") }); + const FString Result = AssetExportNaming::ResolveCollision( + TEXT("SM_Rock_7.uasset"), + EExportCollisionPolicy::Index, + [&Folder](const FString& Name) { return Folder.Contains(Name); }); + + TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_8.uasset"))); + TestFalse(TEXT("Must not contain a doubled index"), Result.Contains(TEXT("_7_"))); + }); + + It("keeps zero padding when incrementing", [this]() + { + const FFakeFolder Folder({ TEXT("SM_Rock_007.uasset") }); + const FString Result = AssetExportNaming::ResolveCollision( + TEXT("SM_Rock_007.uasset"), + EExportCollisionPolicy::Index, + [&Folder](const FString& Name) { return Folder.Contains(Name); }); + + TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_008.uasset"))); + }); + + It("widens padding when the increment carries", [this]() + { + const FFakeFolder Folder({ TEXT("SM_Rock_99.uasset") }); + const FString Result = AssetExportNaming::ResolveCollision( + TEXT("SM_Rock_99.uasset"), + EExportCollisionPolicy::Index, + [&Folder](const FString& Name) { return Folder.Contains(Name); }); + + TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_100.uasset"))); + }); + + It("does not mistake a version suffix for an index", [this]() + { + const FFakeFolder Folder({ TEXT("SM_Rock_02_v3.uasset") }); + const FString Result = AssetExportNaming::ResolveCollision( + TEXT("SM_Rock_02_v3.uasset"), + EExportCollisionPolicy::Index, + [&Folder](const FString& Name) { return Folder.Contains(Name); }); + + TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_02_v3_1.uasset"))); + }); + + It("preserves the extension", [this]() + { + const FFakeFolder Folder({ TEXT("SM_Rock.fbx") }); + const FString Result = AssetExportNaming::ResolveCollision( + TEXT("SM_Rock.fbx"), + EExportCollisionPolicy::Index, + [&Folder](const FString& Name) { return Folder.Contains(Name); }); + + TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_1.fbx"))); + }); + + It("handles a name with no extension", [this]() + { + const FFakeFolder Folder({ TEXT("SM_Rock") }); + const FString Result = AssetExportNaming::ResolveCollision( + TEXT("SM_Rock"), + EExportCollisionPolicy::Index, + [&Folder](const FString& Name) { return Folder.Contains(Name); }); + + TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_1"))); + }); + + It("skips over a gap-free run of taken indices", [this]() + { + const FFakeFolder Folder({ + TEXT("T_Wall.png"), TEXT("T_Wall_1.png"), TEXT("T_Wall_2.png"), TEXT("T_Wall_3.png") }); + const FString Result = AssetExportNaming::ResolveCollision( + TEXT("T_Wall.png"), + EExportCollisionPolicy::Index, + [&Folder](const FString& Name) { return Folder.Contains(Name); }); + + TestEqual(TEXT("Result"), Result, FString(TEXT("T_Wall_4.png"))); + }); + + It("is idempotent for a name that does not collide", [this]() + { + const FFakeFolder Folder({ TEXT("Other.uasset") }); + const auto Predicate = [&Folder](const FString& Name) { return Folder.Contains(Name); }; + + const FString First = AssetExportNaming::ResolveCollision(TEXT("SM_Rock.uasset"), EExportCollisionPolicy::Index, Predicate); + const FString Second = AssetExportNaming::ResolveCollision(First, EExportCollisionPolicy::Index, Predicate); + + TestEqual(TEXT("Stable"), Second, First); + }); + }); +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/AssetUsageAuditTests/Private/AssetUsageExporter.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetUsageExporter.spec.cpp new file mode 100644 index 0000000..838e093 --- /dev/null +++ b/Source/AssetUsageAuditTests/Private/AssetUsageExporter.spec.cpp @@ -0,0 +1,296 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetUsageExporter.h" +#include "AssetExportNaming.h" +#include "HAL/FileManager.h" +#include "Misc/AutomationTest.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" + +#if WITH_DEV_AUTOMATION_TESTS + +BEGIN_DEFINE_SPEC(AssetUsageExporterSpec, + "AssetUsageAudit.Exporter", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + + FString ScratchDir; + + /** Create a throwaway folder under Saved/ so a failed run never touches project content. */ + void MakeScratchDir(const TCHAR* Suffix) + { + ScratchDir = FPaths::ConvertRelativePathToFull( + FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("AssetUsageAuditTests"), Suffix)); + + IFileManager::Get().DeleteDirectory(*ScratchDir, false, true); + IFileManager::Get().MakeDirectory(*ScratchDir, true); + } + + void RemoveScratchDir() + { + if (!ScratchDir.IsEmpty()) + { + IFileManager::Get().DeleteDirectory(*ScratchDir, false, true); + } + } + + void WriteStubFile(const FString& FileName) + { + FFileHelper::SaveStringToFile(TEXT("stub"), *FPaths::Combine(ScratchDir, FileName)); + } + + bool FileExistsInScratch(const FString& FileName) const + { + return IFileManager::Get().FileExists(*FPaths::Combine(ScratchDir, FileName)); + } + +END_DEFINE_SPEC(AssetUsageExporterSpec) + +void AssetUsageExporterSpec::Define() +{ + Describe("ResolvePackageFilePath", [this]() + { + It("returns empty for a package that does not exist", [this]() + { + const FString Path = FAssetUsageExporter::ResolvePackageFilePath( + FName(TEXT("/Game/ThisPackageDoesNotExist_AssetUsageAuditTest"))); + + TestTrue(TEXT("no file for a missing package"), Path.IsEmpty()); + }); + + It("returns empty for a script package rather than inventing a path", [this]() + { + // Script packages are code. Handing back a plausible path would make the exporter + // report a copy it never performed. + const FString Path = FAssetUsageExporter::ResolvePackageFilePath(FName(TEXT("/Script/Engine"))); + + TestTrue(TEXT("no file for a script package"), Path.IsEmpty()); + }); + + It("returns empty for a malformed package name", [this]() + { + TestTrue(TEXT("empty name"), FAssetUsageExporter::ResolvePackageFilePath(FName()).IsEmpty()); + TestTrue(TEXT("not a long package name"), FAssetUsageExporter::ResolvePackageFilePath(FName(TEXT("NotAPackage"))).IsEmpty()); + }); + }); + + Describe("ExportPackageFiles", [this]() + { + AfterEach([this]() + { + RemoveScratchDir(); + }); + + It("fails cleanly when no target directory is given", [this]() + { + FAssetUsageExporter::FOptions Options; + Options.TargetDirectory.Reset(); + + // The exporter logs this at Error level on purpose - it is a setup mistake the user + // must see in the Output Log. The automation framework treats any logged Error during + // a test as a failure, so the expected message has to be declared. + AddExpectedError(TEXT("Export aborted: no target directory was given"), EAutomationExpectedErrorFlags::Contains, 1); + + const FAssetUsageExporter::FResult Result = + FAssetUsageExporter::ExportPackageFiles({ FName(TEXT("/Game/Whatever")) }, Options); + + TestFalse(TEXT("not successful"), Result.bSuccess); + TestTrue(TEXT("reports why"), Result.Errors.Num() > 0); + TestEqual(TEXT("copied nothing"), Result.FilesCopied, 0); + }); + + It("creates the target directory when it is missing", [this]() + { + MakeScratchDir(TEXT("CreatesDir")); + + const FString Nested = FPaths::Combine(ScratchDir, TEXT("Nested/Deeper")); + + FAssetUsageExporter::FOptions Options; + Options.TargetDirectory = Nested; + + FAssetUsageExporter::ExportPackageFiles({}, Options); + + TestTrue(TEXT("directory created"), IFileManager::Get().DirectoryExists(*Nested)); + }); + + It("counts a package with no file on disk without calling it an error", [this]() + { + // A script package or an unsaved asset is an ordinary outcome, not a failure. Folding + // it into Errors would bury real copy failures in noise. + MakeScratchDir(TEXT("MissingOnDisk")); + + FAssetUsageExporter::FOptions Options; + Options.TargetDirectory = ScratchDir; + Options.bIncludeExternalPackages = false; + + const FAssetUsageExporter::FResult Result = FAssetUsageExporter::ExportPackageFiles( + { FName(TEXT("/Game/NoSuchAsset_AssetUsageAuditTest")) }, Options); + + TestEqual(TEXT("counted as missing"), Result.FilesMissingOnDisk, 1); + TestEqual(TEXT("no errors"), Result.Errors.Num(), 0); + TestEqual(TEXT("nothing copied"), Result.FilesCopied, 0); + }); + + It("reports progress with a total that matches the request", [this]() + { + MakeScratchDir(TEXT("Progress")); + + FAssetUsageExporter::FOptions Options; + Options.TargetDirectory = ScratchDir; + Options.bIncludeExternalPackages = false; + + int32 SeenTotal = INDEX_NONE; + int32 Calls = 0; + + Options.OnProgress = [&SeenTotal, &Calls](int32 Done, int32 Total) + { + SeenTotal = Total; + ++Calls; + return true; + }; + + const TArray Packages = { + FName(TEXT("/Game/A_AssetUsageAuditTest")), + FName(TEXT("/Game/B_AssetUsageAuditTest")), + FName(TEXT("/Game/C_AssetUsageAuditTest")) + }; + + FAssetUsageExporter::ExportPackageFiles(Packages, Options); + + TestEqual(TEXT("total matches"), SeenTotal, 3); + TestEqual(TEXT("one call per package"), Calls, 3); + }); + + It("stops when the progress callback asks it to", [this]() + { + MakeScratchDir(TEXT("Cancel")); + + FAssetUsageExporter::FOptions Options; + Options.TargetDirectory = ScratchDir; + Options.bIncludeExternalPackages = false; + + int32 Calls = 0; + Options.OnProgress = [&Calls](int32, int32) + { + ++Calls; + return false; // cancel immediately + }; + + const TArray Packages = { + FName(TEXT("/Game/A_AssetUsageAuditTest")), + FName(TEXT("/Game/B_AssetUsageAuditTest")), + FName(TEXT("/Game/C_AssetUsageAuditTest")) + }; + + const FAssetUsageExporter::FResult Result = FAssetUsageExporter::ExportPackageFiles(Packages, Options); + + TestTrue(TEXT("flagged as cancelled"), Result.bCancelled); + TestFalse(TEXT("not reported successful"), Result.bSuccess); + TestEqual(TEXT("stopped at the first item"), Calls, 1); + }); + + It("de-duplicates a package listed twice", [this]() + { + MakeScratchDir(TEXT("Dedup")); + + FAssetUsageExporter::FOptions Options; + Options.TargetDirectory = ScratchDir; + Options.bIncludeExternalPackages = false; + + int32 Calls = 0; + Options.OnProgress = [&Calls](int32, int32) { ++Calls; return true; }; + + const FName Same(TEXT("/Game/Duplicate_AssetUsageAuditTest")); + FAssetUsageExporter::ExportPackageFiles({ Same, Same, Same }, Options); + + TestEqual(TEXT("processed once"), Calls, 1); + }); + }); + + Describe("Collision policy as the exporter applies it", [this]() + { + AfterEach([this]() + { + RemoveScratchDir(); + }); + + It("picks an indexed name against real files on disk", [this]() + { + // The exporter feeds a filesystem predicate into ResolveCollision. This checks the two + // sides agree: a file that exists on disk must be treated as taken. + MakeScratchDir(TEXT("Collision")); + + WriteStubFile(TEXT("SM_Rock.uasset")); + WriteStubFile(TEXT("SM_Rock_1.uasset")); + + IFileManager& FileManager = IFileManager::Get(); + const FString Dir = ScratchDir; + + const FString Resolved = AssetExportNaming::ResolveCollision( + TEXT("SM_Rock.uasset"), + EExportCollisionPolicy::Index, + [&FileManager, &Dir](const FString& Candidate) + { + return FileManager.FileExists(*FPaths::Combine(Dir, Candidate)); + }); + + TestEqual(TEXT("skips both taken names"), Resolved, FString(TEXT("SM_Rock_2.uasset"))); + }); + + It("keeps the original name when the folder is empty", [this]() + { + MakeScratchDir(TEXT("NoCollision")); + + IFileManager& FileManager = IFileManager::Get(); + const FString Dir = ScratchDir; + + const FString Resolved = AssetExportNaming::ResolveCollision( + TEXT("SM_Rock.uasset"), + EExportCollisionPolicy::Index, + [&FileManager, &Dir](const FString& Candidate) + { + return FileManager.FileExists(*FPaths::Combine(Dir, Candidate)); + }); + + TestEqual(TEXT("unchanged"), Resolved, FString(TEXT("SM_Rock.uasset"))); + }); + }); + + Describe("FResult::Summarise", [this]() + { + It("always states how many files were copied", [this]() + { + FAssetUsageExporter::FResult Result; + Result.FilesCopied = 7; + + TestTrue(TEXT("mentions the count"), Result.Summarise().Contains(TEXT("7 copied"))); + }); + + It("mentions renames, overwrites and cancellation when they happened", [this]() + { + FAssetUsageExporter::FResult Result; + Result.FilesCopied = 3; + Result.FilesRenamed = 2; + Result.FilesOverwritten = 1; + Result.bCancelled = true; + + const FString Summary = Result.Summarise(); + + TestTrue(TEXT("renames"), Summary.Contains(TEXT("renamed"))); + TestTrue(TEXT("overwrites"), Summary.Contains(TEXT("overwritten"))); + TestTrue(TEXT("cancellation"), Summary.Contains(TEXT("cancelled"))); + }); + + It("stays quiet about categories that did not occur", [this]() + { + FAssetUsageExporter::FResult Result; + Result.FilesCopied = 1; + + const FString Summary = Result.Summarise(); + + TestFalse(TEXT("no rename noise"), Summary.Contains(TEXT("renamed"))); + TestFalse(TEXT("no failure noise"), Summary.Contains(TEXT("failed"))); + }); + }); +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/AssetUsageAuditTests/Private/AssetUsageExporterLive.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetUsageExporterLive.spec.cpp new file mode 100644 index 0000000..aacd08c --- /dev/null +++ b/Source/AssetUsageAuditTests/Private/AssetUsageExporterLive.spec.cpp @@ -0,0 +1,446 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetUsageExporter.h" + +#include "AssetRegistry/ARFilter.h" +#include "AssetRegistry/IAssetRegistry.h" +#include "Engine/Level.h" +#include "Engine/World.h" +#include "HAL/FileManager.h" +#include "Misc/AutomationTest.h" +#include "Misc/PackageName.h" +#include "Misc/Paths.h" +#include "Misc/SecureHash.h" + +#if WITH_DEV_AUTOMATION_TESTS + +/** + * Exporter tests against the project's real content. + * + * The sibling spec (AssetUsageAudit.Exporter) covers the exporter's logic with names that + * deliberately do not exist, so it proves the bookkeeping and nothing else: until this file existed + * the exporter had never copied a single real .uasset. The interesting failures - a Perforce + * read-only source, an OFPA level whose external actors live under a stale duplicate folder, a + * package whose file extension is .umap rather than .uasset - only appear on real files. + * + * Everything here discovers its subject from the Asset Registry rather than hardcoding a path, so + * the spec keeps working after content moves, and warns instead of failing on a clone that has no + * suitable asset. + */ +BEGIN_DEFINE_SPEC(AssetUsageExporterLiveSpec, + "AssetUsageAudit.ExporterLive", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + + FString ScratchDir; + + void MakeScratchDir(const TCHAR* Suffix) + { + ScratchDir = FPaths::ConvertRelativePathToFull( + FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("AssetUsageAuditTests"), Suffix)); + + IFileManager::Get().DeleteDirectory(*ScratchDir, false, true); + IFileManager::Get().MakeDirectory(*ScratchDir, true); + } + + void RemoveScratchDir() + { + if (!ScratchDir.IsEmpty()) + { + IFileManager::Get().DeleteDirectory(*ScratchDir, false, true); + } + } + + /** + * A partially scanned registry answers "no such asset" indistinguishably from "no such asset + * exists", which would turn a real failure into a silent skip. + */ + static IAssetRegistry& ReadyRegistry() + { + IAssetRegistry& Registry = IAssetRegistry::GetChecked(); + if (Registry.IsLoadingAssets()) + { + Registry.WaitForCompletion(); + } + return Registry; + } + + /** Files on disk under a package's file, or 0 when the package has no file. */ + static int64 FileSizeOf(FName PackageName) + { + const FString Path = FAssetUsageExporter::ResolvePackageFilePath(PackageName); + return Path.IsEmpty() ? 0 : IFileManager::Get().FileSize(*Path); + } + + static FString HashOf(const FString& FilePath) + { + FMD5Hash Hash = FMD5Hash::HashFile(*FilePath); + return Hash.IsValid() ? LexToString(Hash) : FString(); + } + + /** + * Any ordinary /Game asset that has a file on disk. + * + * Excludes levels (a .umap drags external actors in), OFPA packages, and redirectors. Sorted so + * two runs on the same content pick the same asset and a failure is reproducible. + */ + static FName FindOrdinaryAssetPackage() + { + FARFilter Filter; + Filter.PackagePaths.Add(FName(TEXT("/Game"))); + Filter.bRecursivePaths = true; + Filter.bIncludeOnlyOnDiskAssets = true; + + TArray Candidates; + + ReadyRegistry().EnumerateAssets(Filter, [&Candidates](const FAssetData& AssetData) + { + // Compare class paths rather than calling GetClass(): GetClass() resolves the UClass and + // returns null for anything not loaded, which would let levels through. + if (AssetData.IsRedirector() || AssetData.AssetClassPath == UWorld::StaticClass()->GetClassPathName()) + { + return true; + } + + const FString PackageName = AssetData.PackageName.ToString(); + if (PackageName.Contains(TEXT("__External"))) + { + return true; + } + + Candidates.Add(AssetData.PackageName); + + // A few hundred is plenty to sort; enumerating all 80k just to pick one is waste. + return Candidates.Num() < 512; + }); + + if (Candidates.IsEmpty()) + { + return NAME_None; + } + + Candidates.Sort(FNameLexicalLess()); + + for (FName Candidate : Candidates) + { + if (!FAssetUsageExporter::ResolvePackageFilePath(Candidate).IsEmpty()) + { + return Candidate; + } + } + + return NAME_None; + } + + /** + * How many .uasset files live under a level's registered external roots. + * + * Uses the plural GetExternalActorsPaths / GetExternalObjectsPaths rather than a hand-built + * "__ExternalActors__/..." string. This project contains a stale duplicate of exactly that + * shape - Content/__ExternalActors__/levelinterior_station alongside the live + * Content/__ExternalActors__/Interior_station/levelinterior_station - so a string-built path + * would silently count the wrong folder. + */ + static int32 CountExternalPackages(FName LevelPackage) + { + const FString LevelPackageStr = LevelPackage.ToString(); + + TArray Roots = ULevel::GetExternalActorsPaths(LevelPackageStr); + Roots.Append(ULevel::GetExternalObjectsPaths(LevelPackageStr)); + + // The returned roots overlap: measured on this project, summing the per-root file counts + // gives exactly twice the number of packages that exist. Deduplicate by absolute file path + // rather than by package name, so this stays an independent filesystem measurement instead + // of a restatement of the exporter's own bookkeeping. + TSet UniqueFiles; + + for (const FString& Root : Roots) + { + FString RootDir; + if (!FPackageName::TryConvertLongPackageNameToFilename(Root, RootDir)) + { + continue; + } + + TArray Found; + IFileManager::Get().FindFilesRecursive(Found, *RootDir, TEXT("*.uasset"), true, false); + + for (const FString& File : Found) + { + UniqueFiles.Add(FPaths::ConvertRelativePathToFull(File)); + } + } + + return UniqueFiles.Num(); + } + + /** + * The OFPA level with the fewest external actors. + * + * Smallest on purpose: WP_Main owns roughly 16000 external packages and copying those in a test + * would move gigabytes. Search stops as soon as something small enough turns up. + */ + static FName FindSmallestOfpaLevel(int32& OutExternalCount) + { + FARFilter Filter; + Filter.ClassPaths.Add(UWorld::StaticClass()->GetClassPathName()); + Filter.PackagePaths.Add(FName(TEXT("/Game"))); + Filter.bRecursivePaths = true; + Filter.bIncludeOnlyOnDiskAssets = true; + + FName Best = NAME_None; + int32 BestCount = MAX_int32; + + ReadyRegistry().EnumerateAssets(Filter, [&Best, &BestCount](const FAssetData& AssetData) + { + const int32 Count = CountExternalPackages(AssetData.PackageName); + if (Count > 0 && Count < BestCount) + { + BestCount = Count; + Best = AssetData.PackageName; + } + + // Good enough to prove the expansion works without a long copy. + return BestCount > 40; + }); + + OutExternalCount = (Best.IsNone()) ? 0 : BestCount; + return Best; + } + + int32 CountFilesInScratch() const + { + TArray Found; + IFileManager::Get().FindFilesRecursive(Found, *ScratchDir, TEXT("*.*"), true, false); + return Found.Num(); + } + +END_DEFINE_SPEC(AssetUsageExporterLiveSpec) + +void AssetUsageExporterLiveSpec::Define() +{ + Describe("Copying a real asset", [this]() + { + AfterEach([this]() { RemoveScratchDir(); }); + + It("reproduces the source file byte for byte", [this]() + { + const FName Package = FindOrdinaryAssetPackage(); + if (Package.IsNone()) + { + AddWarning(TEXT("No ordinary /Game asset with a file on disk was found; skipping.")); + return; + } + + MakeScratchDir(TEXT("LiveCopy")); + + FAssetUsageExporter::FOptions Options; + Options.TargetDirectory = ScratchDir; + Options.bIncludeExternalPackages = false; + + const FAssetUsageExporter::FResult Result = + FAssetUsageExporter::ExportPackageFiles({ Package }, Options); + + TestTrue(TEXT("succeeded"), Result.bSuccess); + TestEqual(TEXT("one file copied"), Result.FilesCopied, 1); + TestEqual(TEXT("nothing missing"), Result.FilesMissingOnDisk, 0); + + const FString SourcePath = FAssetUsageExporter::ResolvePackageFilePath(Package); + const FString DestPath = FPaths::Combine(ScratchDir, FPaths::GetCleanFilename(SourcePath)); + + TestTrue(TEXT("destination exists"), IFileManager::Get().FileExists(*DestPath)); + TestEqual(TEXT("same size"), IFileManager::Get().FileSize(*DestPath), FileSizeOf(Package)); + TestEqual(TEXT("same content"), HashOf(DestPath), HashOf(SourcePath)); + }); + + It("leaves the exported file writable even when the source is read-only", [this]() + { + // This project is Perforce-primary, so unopened files are read-only on disk and Windows + // CopyFile carries the attribute across. A read-only export folder is useless to an + // artist and breaks the exporter's own Overwrite policy on the next run. + const FName Package = FindOrdinaryAssetPackage(); + if (Package.IsNone()) + { + AddWarning(TEXT("No ordinary /Game asset with a file on disk was found; skipping.")); + return; + } + + MakeScratchDir(TEXT("LiveReadOnly")); + + FAssetUsageExporter::FOptions Options; + Options.TargetDirectory = ScratchDir; + Options.bIncludeExternalPackages = false; + + FAssetUsageExporter::ExportPackageFiles({ Package }, Options); + + const FString DestPath = FPaths::Combine( + ScratchDir, FPaths::GetCleanFilename(FAssetUsageExporter::ResolvePackageFilePath(Package))); + + TestFalse(TEXT("destination is writable"), IFileManager::Get().IsReadOnly(*DestPath)); + }); + }); + + Describe("Collision policy on real files", [this]() + { + AfterEach([this]() { RemoveScratchDir(); }); + + It("writes a second copy beside the first under the Index policy", [this]() + { + const FName Package = FindOrdinaryAssetPackage(); + if (Package.IsNone()) + { + AddWarning(TEXT("No ordinary /Game asset with a file on disk was found; skipping.")); + return; + } + + MakeScratchDir(TEXT("LiveIndex")); + + FAssetUsageExporter::FOptions Options; + Options.TargetDirectory = ScratchDir; + Options.CollisionPolicy = EExportCollisionPolicy::Index; + Options.bIncludeExternalPackages = false; + + FAssetUsageExporter::ExportPackageFiles({ Package }, Options); + const FAssetUsageExporter::FResult Second = + FAssetUsageExporter::ExportPackageFiles({ Package }, Options); + const FAssetUsageExporter::FResult Third = + FAssetUsageExporter::ExportPackageFiles({ Package }, Options); + + TestEqual(TEXT("second run renamed"), Second.FilesRenamed, 1); + TestEqual(TEXT("third run renamed"), Third.FilesRenamed, 1); + TestEqual(TEXT("three files on disk"), CountFilesInScratch(), 3); + + const FString SourcePath = FAssetUsageExporter::ResolvePackageFilePath(Package); + const FString Base = FPaths::GetBaseFilename(SourcePath); + const FString Ext = FPaths::GetExtension(SourcePath, true); + + TestTrue(TEXT("_1 present"), IFileManager::Get().FileExists(*FPaths::Combine(ScratchDir, Base + TEXT("_1") + Ext))); + TestTrue(TEXT("_2 present"), IFileManager::Get().FileExists(*FPaths::Combine(ScratchDir, Base + TEXT("_2") + Ext))); + }); + + It("replaces the previous file under the Overwrite policy", [this]() + { + const FName Package = FindOrdinaryAssetPackage(); + if (Package.IsNone()) + { + AddWarning(TEXT("No ordinary /Game asset with a file on disk was found; skipping.")); + return; + } + + MakeScratchDir(TEXT("LiveOverwrite")); + + FAssetUsageExporter::FOptions Options; + Options.TargetDirectory = ScratchDir; + Options.CollisionPolicy = EExportCollisionPolicy::Overwrite; + Options.bIncludeExternalPackages = false; + + FAssetUsageExporter::ExportPackageFiles({ Package }, Options); + const FAssetUsageExporter::FResult Second = + FAssetUsageExporter::ExportPackageFiles({ Package }, Options); + + TestTrue(TEXT("second run succeeded"), Second.bSuccess); + TestEqual(TEXT("counted as an overwrite"), Second.FilesOverwritten, 1); + TestEqual(TEXT("nothing renamed"), Second.FilesRenamed, 0); + TestEqual(TEXT("still one file"), CountFilesInScratch(), 1); + }); + }); + + Describe("Mirroring the package tree", [this]() + { + AfterEach([this]() { RemoveScratchDir(); }); + + It("places the file under its /Game path when flattening is off", [this]() + { + const FName Package = FindOrdinaryAssetPackage(); + if (Package.IsNone()) + { + AddWarning(TEXT("No ordinary /Game asset with a file on disk was found; skipping.")); + return; + } + + MakeScratchDir(TEXT("LiveTree")); + + FAssetUsageExporter::FOptions Options; + Options.TargetDirectory = ScratchDir; + Options.bFlatten = false; + Options.bIncludeExternalPackages = false; + + const FAssetUsageExporter::FResult Result = + FAssetUsageExporter::ExportPackageFiles({ Package }, Options); + + TestEqual(TEXT("one file copied"), Result.FilesCopied, 1); + + FString Relative = FPaths::GetPath(Package.ToString()); + Relative.RemoveFromStart(TEXT("/")); + + const FString SourcePath = FAssetUsageExporter::ResolvePackageFilePath(Package); + const FString Expected = FPaths::Combine(ScratchDir, Relative, FPaths::GetCleanFilename(SourcePath)); + + TestTrue(TEXT("mirrored path exists"), IFileManager::Get().FileExists(*Expected)); + }); + }); + + Describe("Exporting an OFPA level", [this]() + { + AfterEach([this]() { RemoveScratchDir(); }); + + It("brings every external actor package along with the map", [this]() + { + // The whole point of the tool: an OFPA map copied on its own opens empty, because its + // actors are separate packages joined to it by a soft dependency edge. + int32 ExpectedExternal = 0; + const FName Level = FindSmallestOfpaLevel(ExpectedExternal); + + if (Level.IsNone()) + { + AddWarning(TEXT("No level with external actor packages was found; skipping.")); + return; + } + + AddInfo(FString::Printf(TEXT("Using level '%s' with %d external packages."), *Level.ToString(), ExpectedExternal)); + + MakeScratchDir(TEXT("LiveOfpa")); + + FAssetUsageExporter::FOptions Options; + Options.TargetDirectory = ScratchDir; + Options.bFlatten = false; // keep the tree: external actor names are GUID-ish and long + Options.bIncludeExternalPackages = true; + + const FAssetUsageExporter::FResult Result = + FAssetUsageExporter::ExportPackageFiles({ Level }, Options); + + TestTrue(TEXT("succeeded"), Result.bSuccess); + TestEqual(TEXT("expanded to every external package"), Result.ExternalPackagesCopied, ExpectedExternal); + TestEqual(TEXT("copied the map plus its externals"), Result.FilesCopied, ExpectedExternal + 1); + TestEqual(TEXT("files on disk match"), CountFilesInScratch(), ExpectedExternal + 1); + + const FString MapPath = FAssetUsageExporter::ResolvePackageFilePath(Level); + TestTrue(TEXT("the map itself is a .umap"), MapPath.EndsWith(TEXT(".umap"))); + }); + + It("copies only the map when external packages are switched off", [this]() + { + int32 ExpectedExternal = 0; + const FName Level = FindSmallestOfpaLevel(ExpectedExternal); + + if (Level.IsNone()) + { + AddWarning(TEXT("No level with external actor packages was found; skipping.")); + return; + } + + MakeScratchDir(TEXT("LiveOfpaOff")); + + FAssetUsageExporter::FOptions Options; + Options.TargetDirectory = ScratchDir; + Options.bIncludeExternalPackages = false; + + const FAssetUsageExporter::FResult Result = + FAssetUsageExporter::ExportPackageFiles({ Level }, Options); + + TestEqual(TEXT("just the map"), Result.FilesCopied, 1); + TestEqual(TEXT("no externals expanded"), Result.ExternalPackagesCopied, 0); + }); + }); +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/AssetUsageAuditTests/Private/AssetUsageGraphFidelity.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetUsageGraphFidelity.spec.cpp new file mode 100644 index 0000000..03b2fd8 --- /dev/null +++ b/Source/AssetUsageAuditTests/Private/AssetUsageGraphFidelity.spec.cpp @@ -0,0 +1,513 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetUsageGraph.h" + +#include "AssetUsageAuditTypes.h" +#include "AssetUsagePaths.h" +#include "LevelUsageResolver.h" +#include "AssetRegistry/ARFilter.h" +#include "AssetRegistry/IAssetRegistry.h" +#include "Engine/World.h" +#include "Misc/AutomationTest.h" + +#if WITH_DEV_AUTOMATION_TESTS + +/** + * Does the graph agree with the Asset Registry it was built from? + * + * This is the automated form of the plan's "open Reference Viewer on ten assets and compare the + * edges by eye". Reference Viewer is a drawing of IAssetRegistry::GetDependencies, so comparing + * against the registry directly checks the same thing, on hundreds of assets instead of ten, and + * keeps checking it after every future change. + * + * What it cannot check: whether the registry itself is right. A reference the registry never + * recorded - an FMOD event resolved by string, a path built by concatenation - is invisible here + * exactly as it is invisible in Reference Viewer. That limitation is the Unknown verdict's whole + * reason for existing, and no test can close it. + */ +BEGIN_DEFINE_SPEC(AssetUsageGraphFidelitySpec, + "AssetUsageAudit.GraphFidelity", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + + static IAssetRegistry& ReadyRegistry() + { + IAssetRegistry& Registry = IAssetRegistry::GetChecked(); + if (Registry.IsLoadingAssets()) + { + Registry.WaitForCompletion(); + } + return Registry; + } + + /** + * Build the graph the way the audit does. + * + * Restricted to a subtree so the spec costs a fraction of a second rather than sweeping 80k + * packages; the invariants under test are per-edge and do not depend on scale. + */ + static void BuildGraph(FAssetUsageGraph& OutGraph, const TCHAR* Root = TEXT("/Game")) + { + FAssetUsageGraphBuildOptions Options; + Options.IncludePackagePaths.Add(Root); + + OutGraph.Build(ReadyRegistry(), Options); + } + + /** + * A stable sample of graph indices. + * + * Sampling by raw index is not reproducible: node indices are assigned in Asset Registry + * enumeration order, which varies between runs even though the graph itself does not. Measured + * on this project the graph is identical run to run - 84505 packages, 370722 edges - while an + * index-strided sample compared 3879, 3847 and 3870 targets on three consecutive runs, because + * index 97 was a different package each time. + * + * Sorting by package name first makes the sample the same every run, so a failure here can be + * reproduced and a pass actually means something. + */ + static TArray StableSample(const FAssetUsageGraph& Graph, int32 Stride = 97) + { + TArray Names; + Names.Reserve(Graph.Num()); + + for (int32 Index = 0; Index < Graph.Num(); ++Index) + { + Names.Add(Graph.GetNode(Index).PackageName); + } + + Names.Sort(FNameLexicalLess()); + + TArray Sample; + Sample.Reserve(Names.Num() / FMath::Max(1, Stride) + 1); + + for (int32 Position = 0; Position < Names.Num(); Position += Stride) + { + const int32 Index = Graph.FindPackageIndex(Names[Position]); + if (Index != INDEX_NONE) + { + Sample.Add(Index); + } + } + + return Sample; + } + + /** Raw registry dependencies for a package, using the tool's mandated query. */ + static TSet RegistryDependencies(FName PackageName) + { + TArray Dependencies; + ReadyRegistry().GetDependencies( + PackageName, + Dependencies, + AssetUsageAudit::MakeTraversalCategory(), + AssetUsageAudit::MakeTraversalQuery()); + + return TSet(Dependencies); + } + +END_DEFINE_SPEC(AssetUsageGraphFidelitySpec) + +void AssetUsageGraphFidelitySpec::Define() +{ + Describe("Edges against the registry", [this]() + { + It("stores every /Game dependency the registry reports", [this]() + { + FAssetUsageGraph Graph; + BuildGraph(Graph); + + if (Graph.Num() == 0) + { + AddWarning(TEXT("The graph came back empty; skipping.")); + return; + } + + // A sample rather than all 84k: this compares against a live registry call per package, + // and the point is to catch a systematic edge-handling mistake, which shows up on the + // first dozen or not at all. + int32 Checked = 0; + int32 Missing = 0; + FString FirstMissing; + + for (int32 Index : StableSample(Graph)) + { + const FAssetUsageNode& Node = Graph.GetNode(Index); + const TSet Expected = RegistryDependencies(Node.PackageName); + + TSet InGraph; + for (const FAssetUsageEdge& Edge : Graph.GetDependencies(Index)) + { + if (Graph.IsValidIndex(Edge.TargetIndex)) + { + InGraph.Add(Graph.GetNode(Edge.TargetIndex).PackageName); + } + } + + for (FName Dependency : Expected) + { + // The graph only holds packages inside the swept roots, so a dependency it + // legitimately does not know about is not a defect. Anything under /Game is. + if (!Dependency.ToString().StartsWith(TEXT("/Game/"))) + { + continue; + } + + if (!InGraph.Contains(Dependency)) + { + ++Missing; + if (FirstMissing.IsEmpty()) + { + FirstMissing = FString::Printf(TEXT("%s -> %s"), + *Node.PackageName.ToString(), *Dependency.ToString()); + } + } + } + + ++Checked; + } + + AddInfo(FString::Printf(TEXT("Compared %d packages against the registry."), Checked)); + + TestTrue(TEXT("something was actually compared"), Checked > 0); + TestEqual(*FString::Printf(TEXT("no /Game edge is missing (first: %s)"), *FirstMissing), Missing, 0); + }); + + It("invents no edge the registry does not report", [this]() + { + // The opposite direction, and the more dangerous one: a fabricated edge would make an + // unused asset look used, which is the failure mode this tool must never have. + FAssetUsageGraph Graph; + BuildGraph(Graph); + + if (Graph.Num() == 0) + { + AddWarning(TEXT("The graph came back empty; skipping.")); + return; + } + + int32 Extra = 0; + FString FirstExtra; + + for (int32 Index : StableSample(Graph)) + { + const FAssetUsageNode& Node = Graph.GetNode(Index); + const TSet Expected = RegistryDependencies(Node.PackageName); + + for (const FAssetUsageEdge& Edge : Graph.GetDependencies(Index)) + { + if (!Graph.IsValidIndex(Edge.TargetIndex)) + { + continue; + } + + const FName Target = Graph.GetNode(Edge.TargetIndex).PackageName; + if (!Expected.Contains(Target)) + { + ++Extra; + if (FirstExtra.IsEmpty()) + { + FirstExtra = FString::Printf(TEXT("%s -> %s"), + *Node.PackageName.ToString(), *Target.ToString()); + } + } + } + } + + TestEqual(*FString::Printf(TEXT("no invented edge (first: %s)"), *FirstExtra), Extra, 0); + }); + + It("agrees with the registry on which edges are hard", [this]() + { + // Hard versus soft is a report column, and the columns are what someone decides on. + // A mislabelled edge is worse than a missing one: it reads as information. + FAssetUsageGraph Graph; + BuildGraph(Graph); + + if (Graph.Num() == 0) + { + AddWarning(TEXT("The graph came back empty; skipping.")); + return; + } + + IAssetRegistry& Registry = ReadyRegistry(); + + // Compare per TARGET, not per edge. The registry may report the same target twice under + // different property masks - a hard reference and a soft one to the same package - and + // the graph faithfully keeps both. Asking "is this edge hard" against a set that only + // answers "is any edge to this target hard" then flags the soft twin as a mismatch. + // + // An earlier version of this spec did exactly that and failed intermittently: whether a + // package with such a pair fell into the sample depended on the sample, so the same code + // passed or failed run to run. The defect was here, not in the graph. + int32 Compared = 0; + int32 Disagreements = 0; + TArray Details; + + for (int32 Index : StableSample(Graph)) + { + const FAssetUsageNode& Node = Graph.GetNode(Index); + + TArray HardOnly; + Registry.GetDependencies( + Node.PackageName, + HardOnly, + AssetUsageAudit::MakeTraversalCategory(), + UE::AssetRegistry::FDependencyQuery(UE::AssetRegistry::EDependencyQuery::Hard)); + + const TSet RegistryHardTargets(HardOnly); + + // Targets the graph considers hard by at least one edge - the same question the + // registry's hard-only query answers. + TSet GraphHardTargets; + TSet GraphAllTargets; + + for (const FAssetUsageEdge& Edge : Graph.GetDependencies(Index)) + { + if (!Graph.IsValidIndex(Edge.TargetIndex)) + { + continue; + } + + const FName Target = Graph.GetNode(Edge.TargetIndex).PackageName; + GraphAllTargets.Add(Target); + + if (Edge.IsHard()) + { + GraphHardTargets.Add(Target); + } + } + + for (FName Target : GraphAllTargets) + { + const bool bGraphSaysHard = GraphHardTargets.Contains(Target); + const bool bRegistrySaysHard = RegistryHardTargets.Contains(Target); + + if (bGraphSaysHard != bRegistrySaysHard) + { + ++Disagreements; + + if (Details.Num() < 5) + { + Details.Add(FString::Printf( + TEXT("%s -> %s: graph says %s, registry says %s"), + *Node.PackageName.ToString(), + *Target.ToString(), + bGraphSaysHard ? TEXT("hard") : TEXT("not hard"), + bRegistrySaysHard ? TEXT("hard") : TEXT("not hard"))); + } + } + + ++Compared; + } + } + + AddInfo(FString::Printf(TEXT("Compared the hard flag on %d dependency targets."), Compared)); + + for (const FString& Detail : Details) + { + AddInfo(Detail); + } + + TestTrue(TEXT("something was actually compared"), Compared > 0); + TestEqual(TEXT("hard flags match the registry"), Disagreements, 0); + }); + }); + + Describe("The OFPA invariant, stated as a measurement", [this]() + { + It("finds external actor packages that a hard-only query would lose", [this]() + { + // The regression this guards is silent: a Hard-only query still returns a plausible + // graph, just without any One File Per Actor package in it. Rather than trusting the + // comment, measure the difference the query makes. + IAssetRegistry& Registry = ReadyRegistry(); + + FARFilter Filter; + Filter.ClassPaths.Add(UWorld::StaticClass()->GetClassPathName()); + Filter.PackagePaths.Add(FName(TEXT("/Game"))); + Filter.bRecursivePaths = true; + Filter.bIncludeOnlyOnDiskAssets = true; + + FName LevelWithExternals = NAME_None; + int32 ExternalsUnderCorrectQuery = 0; + + Registry.EnumerateAssets(Filter, [&](const FAssetData& AssetData) + { + TArray All; + Registry.GetDependencies( + AssetData.PackageName, + All, + AssetUsageAudit::MakeTraversalCategory(), + AssetUsageAudit::MakeTraversalQuery()); + + int32 Externals = 0; + for (FName Dependency : All) + { + if (AssetUsagePaths::IsExternalPackage(Dependency)) + { + ++Externals; + } + } + + if (Externals > 0) + { + LevelWithExternals = AssetData.PackageName; + ExternalsUnderCorrectQuery = Externals; + return false; + } + + return true; + }); + + if (LevelWithExternals.IsNone()) + { + AddWarning(TEXT("No level with external actor packages was found; skipping.")); + return; + } + + TArray HardOnly; + Registry.GetDependencies( + LevelWithExternals, + HardOnly, + AssetUsageAudit::MakeTraversalCategory(), + UE::AssetRegistry::FDependencyQuery(UE::AssetRegistry::EDependencyQuery::Hard)); + + int32 ExternalsUnderHardQuery = 0; + for (FName Dependency : HardOnly) + { + if (AssetUsagePaths::IsExternalPackage(Dependency)) + { + ++ExternalsUnderHardQuery; + } + } + + AddInfo(FString::Printf( + TEXT("'%s': %d external packages with the tool's query, %d with a Hard-only query."), + *LevelWithExternals.ToString(), ExternalsUnderCorrectQuery, ExternalsUnderHardQuery)); + + TestTrue(TEXT("the tool's query finds external packages"), ExternalsUnderCorrectQuery > 0); + + // The engine emits these edges as Game|Build without Hard. If this ever becomes + // non-zero the engine has changed and the surrounding comments need revisiting. + TestEqual(TEXT("a Hard-only query finds none of them"), ExternalsUnderHardQuery, 0); + }); + }); + + Describe("Sublevels and their parent map", [this]() + { + It("attributes a sublevel's assets to the parent map as well", [this]() + { + // Plan item 4. A streaming sublevel is reached from the parent's own package, which is + // a structural crossing, so the parent's sweep should see through it. Asserted rather + // than assumed: the map-boundary rule refuses most crossings, and getting it slightly + // wrong would silently drop every sublevel's contents from the parent. + IAssetRegistry& Registry = ReadyRegistry(); + const FTopLevelAssetPath WorldClass = UWorld::StaticClass()->GetClassPathName(); + + FARFilter Filter; + Filter.ClassPaths.Add(WorldClass); + Filter.PackagePaths.Add(FName(TEXT("/Game"))); + Filter.bRecursivePaths = true; + Filter.bIncludeOnlyOnDiskAssets = true; + + FName Parent = NAME_None; + FName Sublevel = NAME_None; + + Registry.EnumerateAssets(Filter, [&](const FAssetData& AssetData) + { + TArray Dependencies; + Registry.GetDependencies( + AssetData.PackageName, + Dependencies, + AssetUsageAudit::MakeTraversalCategory(), + AssetUsageAudit::MakeTraversalQuery()); + + for (FName Dependency : Dependencies) + { + TArray InPackage; + Registry.GetAssetsByPackageName(Dependency, InPackage, true); + + for (const FAssetData& Inner : InPackage) + { + if (Inner.AssetClassPath == WorldClass && Dependency != AssetData.PackageName) + { + Parent = AssetData.PackageName; + Sublevel = Dependency; + return false; + } + } + } + + return true; + }); + + if (Parent.IsNone()) + { + AddWarning(TEXT("No map referencing another map directly was found; skipping.")); + return; + } + + AddInfo(FString::Printf(TEXT("Parent '%s' references '%s'."), *Parent.ToString(), *Sublevel.ToString())); + + FAssetUsageGraph Graph; + BuildGraph(Graph); + + const int32 SublevelIndex = Graph.FindPackageIndex(Sublevel); + if (SublevelIndex == INDEX_NONE) + { + AddWarning(TEXT("The sublevel is outside the built graph; skipping.")); + return; + } + + FLevelUsageResolver Resolver(Graph, Registry); + + FLevelUsageResolveOptions Options; + Options.LevelPackages = { Parent }; + Options.bRecordRoutes = false; + + const FLevelUsageResult Result = Resolver.Resolve(Options); + + TestTrue(TEXT("the parent's sweep reaches the sublevel"), Result.IsReachableFromAnyLevel(SublevelIndex)); + + // Reaching the sublevel package is necessary but not sufficient - a foreign map is also + // "reached", just not expanded. What matters is that its contents came too. + TArray SublevelDependencies; + Registry.GetDependencies( + Sublevel, + SublevelDependencies, + AssetUsageAudit::MakeTraversalCategory(), + AssetUsageAudit::MakeTraversalQuery()); + + int32 ContentsChecked = 0; + int32 ContentsReached = 0; + + for (FName Dependency : SublevelDependencies) + { + const int32 Index = Graph.FindPackageIndex(Dependency); + if (Index == INDEX_NONE) + { + continue; + } + + ++ContentsChecked; + if (Result.IsReachableFromAnyLevel(Index)) + { + ++ContentsReached; + } + } + + if (ContentsChecked == 0) + { + AddWarning(TEXT("The sublevel has no dependencies inside the graph; nothing to check.")); + return; + } + + AddInfo(FString::Printf(TEXT("%d of %d sublevel dependencies reached from the parent."), + ContentsReached, ContentsChecked)); + + TestEqual(TEXT("the parent sees the sublevel's contents"), ContentsReached, ContentsChecked); + }); + }); +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/AssetUsageAuditTests/Private/AssetUsagePaths.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetUsagePaths.spec.cpp new file mode 100644 index 0000000..760032a --- /dev/null +++ b/Source/AssetUsageAuditTests/Private/AssetUsagePaths.spec.cpp @@ -0,0 +1,163 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetUsagePaths.h" +#include "Misc/AutomationTest.h" + +#if WITH_DEV_AUTOMATION_TESTS + +BEGIN_DEFINE_SPEC(AssetUsagePathsSpec, + "AssetUsageAudit.Paths", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) +END_DEFINE_SPEC(AssetUsagePathsSpec) + +void AssetUsagePathsSpec::Define() +{ + Describe("IsExternalPackage", [this]() + { + It("detects an OFPA external actor package", [this]() + { + // Real shape from this project: /Game/__ExternalActors__/Space/Maps/WP_Main/8/AB/<22 chars> + TestTrue(TEXT("external actor"), + AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/__ExternalActors__/Space/Maps/WP_Main/8/AB/QWERTYUIOPASDFGHJKLZXC")))); + }); + + It("detects an external objects package", [this]() + { + TestTrue(TEXT("external object"), + AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/__ExternalObjects__/Space/Maps/L_3i_Hub/1/2A/ABC")))); + }); + + It("does not flag an ordinary content package", [this]() + { + TestFalse(TEXT("ordinary asset"), + AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/Space/Art/SM_Rock")))); + }); + + It("does not flag a level that merely lives next to external actors", [this]() + { + TestFalse(TEXT("the level itself is not external"), + AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/Space/Maps/WP_Main")))); + }); + }); + + Describe("IsScriptPackage", [this]() + { + It("flags /Script packages", [this]() + { + TestTrue(TEXT("script"), AssetUsagePaths::IsScriptPackage(FName(TEXT("/Script/Space")))); + }); + + It("does not flag content that starts with the same letters", [this]() + { + TestFalse(TEXT("not script"), AssetUsagePaths::IsScriptPackage(FName(TEXT("/Scripted/Foo")))); + }); + }); + + Describe("NormalizeExclusionPrefix", [this]() + { + It("converts content-relative form to package form", [this]() + { + TestEqual(TEXT("Content/3rdParty"), + AssetUsagePaths::NormalizeExclusionPrefix(TEXT("Content/3rdParty")), FString(TEXT("/Game/3rdParty"))); + }); + + It("strips a trailing slash", [this]() + { + TestEqual(TEXT("trailing slash"), + AssetUsagePaths::NormalizeExclusionPrefix(TEXT("Content/3rdParty/")), FString(TEXT("/Game/3rdParty"))); + }); + + It("leaves package form untouched", [this]() + { + TestEqual(TEXT("already package form"), + AssetUsagePaths::NormalizeExclusionPrefix(TEXT("/Game/Zombie")), FString(TEXT("/Game/Zombie"))); + }); + + It("maps bare Content to the game root", [this]() + { + TestEqual(TEXT("bare Content"), + AssetUsagePaths::NormalizeExclusionPrefix(TEXT("Content")), FString(TEXT("/Game"))); + }); + + It("normalizes backslashes pasted from Explorer", [this]() + { + TestEqual(TEXT("backslashes"), + AssetUsagePaths::NormalizeExclusionPrefix(TEXT("Content\\3rdParty\\Fab")), FString(TEXT("/Game/3rdParty/Fab"))); + }); + + It("returns empty for an empty or slash-only prefix", [this]() + { + TestTrue(TEXT("empty"), AssetUsagePaths::NormalizeExclusionPrefix(TEXT("")).IsEmpty()); + TestTrue(TEXT("slashes only"), AssetUsagePaths::NormalizeExclusionPrefix(TEXT("///")).IsEmpty()); + }); + }); + + Describe("IsPathExcluded", [this]() + { + It("excludes a package under the prefix", [this]() + { + const TArray Excluded = { TEXT("Content/3rdParty") }; + TestTrue(TEXT("under prefix"), + AssetUsagePaths::IsPathExcluded(FName(TEXT("/Game/3rdParty/Galaxy/SM_Star")), Excluded)); + }); + + It("does not exclude a sibling folder sharing a name prefix", [this]() + { + // The bug this guards: a naive StartsWith would exclude /Game/ArtSource when the user + // only asked to exclude /Game/Art. + const TArray Excluded = { TEXT("/Game/Art") }; + TestFalse(TEXT("ArtSource must survive"), + AssetUsagePaths::IsPathExcluded(FName(TEXT("/Game/ArtSource/SM_Rock")), Excluded)); + TestTrue(TEXT("Art itself is excluded"), + AssetUsagePaths::IsPathExcluded(FName(TEXT("/Game/Art/SM_Rock")), Excluded)); + }); + + It("matches case-insensitively", [this]() + { + const TArray Excluded = { TEXT("content/ZOMBIE") }; + TestTrue(TEXT("case insensitive"), + AssetUsagePaths::IsPathExcluded(FName(TEXT("/Game/Zombie/SK_Walker")), Excluded)); + }); + + It("returns false for an empty exclusion list", [this]() + { + const TArray Excluded; + TestFalse(TEXT("nothing excluded"), + AssetUsagePaths::IsPathExcluded(FName(TEXT("/Game/Space/Art/SM_Rock")), Excluded)); + }); + }); + + Describe("ToProjectRelativePath", [this]() + { + It("converts an asset package", [this]() + { + TestEqual(TEXT("asset"), + AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/Space/Art/SM_Rock")), false), + FString(TEXT("Content/Space/Art/SM_Rock.uasset"))); + }); + + It("uses the umap extension for levels", [this]() + { + TestEqual(TEXT("level"), + AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/Space/Maps/WP_Main")), true), + FString(TEXT("Content/Space/Maps/WP_Main.umap"))); + }); + + It("returns empty for script and engine packages rather than inventing a path", [this]() + { + TestTrue(TEXT("script"), + AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Script/Space")), false).IsEmpty()); + TestTrue(TEXT("engine"), + AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Engine/BasicShapes/Cube")), false).IsEmpty()); + }); + + It("converts an external actor package, which is still under /Game", [this]() + { + TestEqual(TEXT("external actor"), + AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/__ExternalActors__/Space/Maps/WP_Main/8/AB/ABC")), false), + FString(TEXT("Content/__ExternalActors__/Space/Maps/WP_Main/8/AB/ABC.uasset"))); + }); + }); +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/AssetUsageAuditTests/Private/AssetUsageReportWriter.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetUsageReportWriter.spec.cpp new file mode 100644 index 0000000..23f62fe --- /dev/null +++ b/Source/AssetUsageAuditTests/Private/AssetUsageReportWriter.spec.cpp @@ -0,0 +1,228 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetUsageReportWriter.h" +#include "AssetUsageAuditor.h" +#include "AssetUsageAuditTypes.h" +#include "Dom/JsonObject.h" +#include "Misc/AutomationTest.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonSerializer.h" + +#if WITH_DEV_AUTOMATION_TESTS + +namespace +{ + FAssetUsageAuditResult MakeSampleResult() + { + FAssetUsageAuditResult Result; + + Result.Header.GeneratedAt = FDateTime(2026, 9, 2, 12, 0, 0); + Result.Header.ProjectName = TEXT("Space"); + Result.Header.EngineVersion = TEXT("5.6.1"); + Result.Header.ToolVersion = TEXT("0.1"); + Result.Header.LevelsScanned = 2; + Result.Header.AssetsScanned = 3; + Result.Header.AppliedFilters.Add(TEXT("Types: StaticMesh")); + + FAssetUsageRow& Used = Result.Rows.AddDefaulted_GetRef(); + Used.PackageName = FName(TEXT("/Game/Space/Art/SM_Rock")); + Used.AssetName = FName(TEXT("SM_Rock")); + Used.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine.StaticMesh")); + Used.PathFromProjectRoot = TEXT("Content/Space/Art/SM_Rock.uasset"); + Used.Verdict = EAssetUsageVerdict::UsedOnLevel; + Used.Levels = { FName(TEXT("/Game/Space/Maps/WP_Main")), FName(TEXT("/Game/Space/Maps/L_3i_Hub")) }; + Used.HardReferenceCount = 3; + Used.SoftReferenceCount = 1; + Used.Provenance = EAssetUsageProvenance::HardReference | EAssetUsageProvenance::ExternalActor; + Used.Route = TEXT("WP_Main -> BP_Rock -> SM_Rock"); + + FAssetUsageRow& Unused = Result.Rows.AddDefaulted_GetRef(); + Unused.PackageName = FName(TEXT("/Game/Space/Art/SM_Orphan")); + Unused.AssetName = FName(TEXT("SM_Orphan")); + Unused.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine.StaticMesh")); + Unused.PathFromProjectRoot = TEXT("Content/Space/Art/SM_Orphan.uasset"); + Unused.Verdict = EAssetUsageVerdict::Unreferenced; + + return Result; + } +} + +BEGIN_DEFINE_SPEC(AssetUsageReportWriterSpec, + "AssetUsageAudit.ReportWriter", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) +END_DEFINE_SPEC(AssetUsageReportWriterSpec) + +void AssetUsageReportWriterSpec::Define() +{ + Describe("EscapeCsvField", [this]() + { + It("leaves a plain field untouched", [this]() + { + TestEqual(TEXT("plain"), FAssetUsageReportWriter::EscapeCsvField(TEXT("SM_Rock"), TEXT(';')), FString(TEXT("SM_Rock"))); + }); + + It("quotes a field containing the delimiter", [this]() + { + TestEqual(TEXT("delimiter"), + FAssetUsageReportWriter::EscapeCsvField(TEXT("A;B"), TEXT(';')), FString(TEXT("\"A;B\""))); + }); + + It("does not quote a field containing a different delimiter", [this]() + { + TestEqual(TEXT("other delimiter"), + FAssetUsageReportWriter::EscapeCsvField(TEXT("A,B"), TEXT(';')), FString(TEXT("A,B"))); + }); + + It("doubles embedded quotes", [this]() + { + TestEqual(TEXT("quotes"), + FAssetUsageReportWriter::EscapeCsvField(TEXT("say \"hi\""), TEXT(';')), FString(TEXT("\"say \"\"hi\"\"\""))); + }); + + It("quotes a field containing a newline", [this]() + { + // Route strings are built from node titles, and Blueprint node titles really do + // contain newlines. An unquoted one would split the row and corrupt the whole file. + TestEqual(TEXT("newline"), + FAssetUsageReportWriter::EscapeCsvField(TEXT("A\nB"), TEXT(';')), FString(TEXT("\"A\nB\""))); + }); + }); + + Describe("BuildCsv", [this]() + { + It("emits one header row and one row per asset", [this]() + { + const FAssetUsageAuditResult Sample = MakeSampleResult(); + FAssetUsageReportWriter::FOptions Options; + + const FString Csv = FAssetUsageReportWriter::BuildCsv(Sample, Options); + + TArray Lines; + Csv.ParseIntoArrayLines(Lines); + + const int32 CommentLines = Lines.FilterByPredicate([](const FString& Line) { return Line.StartsWith(TEXT("#")); }).Num(); + + TestTrue(TEXT("has comment header"), CommentLines > 0); + TestEqual(TEXT("comments + header + 2 rows"), Lines.Num(), CommentLines + 1 + 2); + }); + + It("records the filters that produced the report", [this]() + { + const FAssetUsageAuditResult Sample = MakeSampleResult(); + const FString Csv = FAssetUsageReportWriter::BuildCsv(Sample, FAssetUsageReportWriter::FOptions()); + + TestTrue(TEXT("filter recorded"), Csv.Contains(TEXT("# Filter: Types: StaticMesh"))); + }); + + It("joins multiple levels into one cell with the configured separator", [this]() + { + const FAssetUsageAuditResult Sample = MakeSampleResult(); + FAssetUsageReportWriter::FOptions Options; + Options.CsvMultiValueSeparator = TEXT("|"); + + const FString Csv = FAssetUsageReportWriter::BuildCsv(Sample, Options); + + TestTrue(TEXT("levels joined"), + Csv.Contains(TEXT("/Game/Space/Maps/WP_Main|/Game/Space/Maps/L_3i_Hub"))); + }); + + It("carries the verdict verbatim", [this]() + { + const FString Csv = FAssetUsageReportWriter::BuildCsv(MakeSampleResult(), FAssetUsageReportWriter::FOptions()); + + TestTrue(TEXT("UsedOnLevel present"), Csv.Contains(TEXT("UsedOnLevel"))); + TestTrue(TEXT("Unreferenced present"), Csv.Contains(TEXT("Unreferenced"))); + }); + + It("warns in the header that Unknown does not mean unused", [this]() + { + // The single most dangerous misreading of this report. It must be stated in the file + // itself, because the file outlives the UI that explained it. + const FString Csv = FAssetUsageReportWriter::BuildCsv(MakeSampleResult(), FAssetUsageReportWriter::FOptions()); + + TestTrue(TEXT("warning present"), Csv.Contains(TEXT("does NOT mean unused"))); + }); + }); + + Describe("BuildJson", [this]() + { + It("produces valid, parseable JSON", [this]() + { + const FString Json = FAssetUsageReportWriter::BuildJson(MakeSampleResult()); + + TSharedPtr Root; + const TSharedRef> Reader = TJsonReaderFactory<>::Create(Json); + + TestTrue(TEXT("parses"), FJsonSerializer::Deserialize(Reader, Root) && Root.IsValid()); + }); + + It("carries the header, verdict counts and one entry per asset", [this]() + { + const FString Json = FAssetUsageReportWriter::BuildJson(MakeSampleResult()); + + TSharedPtr Root; + const TSharedRef> Reader = TJsonReaderFactory<>::Create(Json); + if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid()) + { + AddError(TEXT("JSON did not parse")); + return; + } + + const TSharedPtr* Header = nullptr; + TestTrue(TEXT("header object"), Root->TryGetObjectField(TEXT("header"), Header)); + if (Header) + { + TestEqual(TEXT("project"), (*Header)->GetStringField(TEXT("project")), FString(TEXT("Space"))); + TestEqual(TEXT("levelsScanned"), (*Header)->GetIntegerField(TEXT("levelsScanned")), 2); + } + + const TSharedPtr* Counts = nullptr; + TestTrue(TEXT("verdictCounts object"), Root->TryGetObjectField(TEXT("verdictCounts"), Counts)); + if (Counts) + { + TestEqual(TEXT("usedOnLevel"), (*Counts)->GetIntegerField(TEXT("usedOnLevel")), 1); + TestEqual(TEXT("unreferenced"), (*Counts)->GetIntegerField(TEXT("unreferenced")), 1); + } + + const TArray>* Assets = nullptr; + TestTrue(TEXT("assets array"), Root->TryGetArrayField(TEXT("assets"), Assets)); + if (Assets) + { + TestEqual(TEXT("two assets"), Assets->Num(), 2); + } + }); + + It("keeps the level list nested rather than flattened to a string", [this]() + { + // The whole point of shipping JSON alongside CSV: a consumer should not have to + // re-split a delimited cell to learn which levels use an asset. + const FString Json = FAssetUsageReportWriter::BuildJson(MakeSampleResult()); + + TSharedPtr Root; + const TSharedRef> Reader = TJsonReaderFactory<>::Create(Json); + if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid()) + { + AddError(TEXT("JSON did not parse")); + return; + } + + const TArray>* Assets = nullptr; + if (!Root->TryGetArrayField(TEXT("assets"), Assets) || Assets->Num() == 0) + { + AddError(TEXT("assets array missing")); + return; + } + + const TSharedPtr First = (*Assets)[0]->AsObject(); + const TArray>* Levels = nullptr; + + TestTrue(TEXT("levels is an array"), First->TryGetArrayField(TEXT("levels"), Levels)); + if (Levels) + { + TestEqual(TEXT("two levels"), Levels->Num(), 2); + } + }); + }); +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/AssetUsageAuditTests/Private/AssetUsageVerdict.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetUsageVerdict.spec.cpp new file mode 100644 index 0000000..c7f4215 --- /dev/null +++ b/Source/AssetUsageAuditTests/Private/AssetUsageVerdict.spec.cpp @@ -0,0 +1,146 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetUsageAuditor.h" +#include "AssetUsageGraph.h" +#include "Misc/AutomationTest.h" + +#if WITH_DEV_AUTOMATION_TESTS + +BEGIN_DEFINE_SPEC(AssetUsageVerdictSpec, + "AssetUsageAudit.Verdict", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) +END_DEFINE_SPEC(AssetUsageVerdictSpec) + +void AssetUsageVerdictSpec::Define() +{ + Describe("ClassifyVerdict", [this]() + { + It("reports UsedOnLevel when a level reaches the asset", [this]() + { + const EAssetUsageVerdict Verdict = FAssetUsageAuditor::ClassifyVerdict( + /*bReachableFromLevel=*/true, + /*bHasReferencers=*/true, + /*bFoundInConfigOrSource=*/false, + /*bIsBlindSpot=*/false); + + TestEqual(TEXT("Verdict"), Verdict, EAssetUsageVerdict::UsedOnLevel); + }); + + It("reports UsedOnLevel even when the asset is also named in config", [this]() + { + // Level reachability is the strongest signal and must win. Otherwise a mesh that is + // both placed on a level and mentioned in an ini would be filed under the weaker + // config verdict and vanish from a per-level report. + const EAssetUsageVerdict Verdict = FAssetUsageAuditor::ClassifyVerdict(true, true, true, false); + + TestEqual(TEXT("Verdict"), Verdict, EAssetUsageVerdict::UsedOnLevel); + }); + + It("reports UsedByAssetsOnly when referenced but no level reaches it", [this]() + { + const EAssetUsageVerdict Verdict = FAssetUsageAuditor::ClassifyVerdict(false, true, false, false); + + TestEqual(TEXT("Verdict"), Verdict, EAssetUsageVerdict::UsedByAssetsOnly); + }); + + It("reports ReferencedFromConfigOrSource when only a text literal names it", [this]() + { + // This is the BP_FirstPersonGameMode case: no asset references it, only + // Config/DefaultEngine.ini does. Reporting it Unreferenced would be a real defect. + const EAssetUsageVerdict Verdict = FAssetUsageAuditor::ClassifyVerdict(false, false, true, false); + + TestEqual(TEXT("Verdict"), Verdict, EAssetUsageVerdict::ReferencedFromConfigOrSource); + }); + + It("prefers the config verdict over UsedByAssetsOnly when both apply", [this]() + { + // Config provenance is more actionable than "some asset points at it" - it tells the + // user the reference is invisible to the registry and where to look for it. + const EAssetUsageVerdict Verdict = FAssetUsageAuditor::ClassifyVerdict(false, true, true, false); + + TestEqual(TEXT("Verdict"), Verdict, EAssetUsageVerdict::ReferencedFromConfigOrSource); + }); + + It("reports Unreferenced only when nothing at all points at it", [this]() + { + const EAssetUsageVerdict Verdict = FAssetUsageAuditor::ClassifyVerdict(false, false, false, false); + + TestEqual(TEXT("Verdict"), Verdict, EAssetUsageVerdict::Unreferenced); + }); + + It("never reports Unreferenced for a registry blind spot", [this]() + { + // An FMOD asset with zero referencers tells us nothing, because FMOD resolves events + // by string path outside the UObject graph. Unknown is the honest answer, and the one + // that stops someone deleting the audio bank. + const EAssetUsageVerdict Verdict = FAssetUsageAuditor::ClassifyVerdict(false, false, false, true); + + TestEqual(TEXT("Verdict"), Verdict, EAssetUsageVerdict::Unknown); + TestNotEqual(TEXT("Must not be Unreferenced"), Verdict, EAssetUsageVerdict::Unreferenced); + }); + + It("still reports UsedOnLevel for a blind-spot asset that a level does reach", [this]() + { + // A positive result is trustworthy even for a blind spot: we found a real edge. + // Only the absence of evidence is uninformative. + const EAssetUsageVerdict Verdict = FAssetUsageAuditor::ClassifyVerdict(true, false, false, true); + + TestEqual(TEXT("Verdict"), Verdict, EAssetUsageVerdict::UsedOnLevel); + }); + + It("produces a distinct string for every verdict", [this]() + { + // The report is parsed by other tools; two verdicts sharing a label would silently + // merge distinct populations. + TSet Seen; + const TArray All = { + EAssetUsageVerdict::UsedOnLevel, + EAssetUsageVerdict::UsedByAssetsOnly, + EAssetUsageVerdict::ReferencedFromConfigOrSource, + EAssetUsageVerdict::Unreferenced, + EAssetUsageVerdict::Unknown + }; + + for (EAssetUsageVerdict Verdict : All) + { + const FString Label = LexToString(Verdict); + TestFalse(FString::Printf(TEXT("Label '%s' must be unique"), *Label), Seen.Contains(Label)); + TestFalse(TEXT("Label must not be empty"), Label.IsEmpty()); + Seen.Add(Label); + } + }); + }); + + Describe("IsRegistryBlindSpot", [this]() + { + It("flags FMOD assets", [this]() + { + FAssetUsageNode Node; + Node.PackageName = FName(TEXT("/Game/FMOD/Events/Explosion")); + Node.ClassPath = FTopLevelAssetPath(TEXT("/Script/FMODStudio.FMODEvent")); + + TestTrue(TEXT("FMOD asset is a blind spot"), FAssetUsageAuditor::IsRegistryBlindSpot(Node)); + }); + + It("flags DataTables, whose rows the registry cannot attribute", [this]() + { + // A level depends on the whole table, so every row looks used even when none is. + FAssetUsageNode Node; + Node.PackageName = FName(TEXT("/Game/Space/Data/DT_Weapons")); + Node.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine.DataTable")); + + TestTrue(TEXT("DataTable is a blind spot"), FAssetUsageAuditor::IsRegistryBlindSpot(Node)); + }); + + It("does not flag an ordinary static mesh", [this]() + { + FAssetUsageNode Node; + Node.PackageName = FName(TEXT("/Game/Space/Art/SM_Rock")); + Node.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine.StaticMesh")); + + TestFalse(TEXT("StaticMesh is not a blind spot"), FAssetUsageAuditor::IsRegistryBlindSpot(Node)); + }); + }); +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/AssetUsageAuditTests/Private/IndirectReferenceScanner.spec.cpp b/Source/AssetUsageAuditTests/Private/IndirectReferenceScanner.spec.cpp new file mode 100644 index 0000000..345feca --- /dev/null +++ b/Source/AssetUsageAuditTests/Private/IndirectReferenceScanner.spec.cpp @@ -0,0 +1,131 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "IndirectReferenceScanner.h" +#include "Misc/AutomationTest.h" + +#if WITH_DEV_AUTOMATION_TESTS + +BEGIN_DEFINE_SPEC(IndirectReferenceScannerSpec, + "AssetUsageAudit.IndirectReferences", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + + TArray Extract(const TCHAR* Line) + { + TArray Result; + FIndirectReferenceScanner::ExtractGamePathsFromLine(Line, Result); + return Result; + } + +END_DEFINE_SPEC(IndirectReferenceScannerSpec) + +void IndirectReferenceScannerSpec::Define() +{ + Describe("ExtractGamePathsFromLine", [this]() + { + // These four lines are copied from this project's Config/DefaultEngine.ini. They are the + // whole reason this scanner exists: nothing in Content references them, so without this + // pass the project's GameMode and GameInstance are reported as unused assets. + It("finds the GameMode from DefaultEngine.ini and strips the _C class suffix", [this]() + { + const TArray Found = Extract( + TEXT("GlobalDefaultGameMode=/Game/Space/Core/GameModes/BP_FirstPersonGameMode.BP_FirstPersonGameMode_C")); + + TestEqual(TEXT("one hit"), Found.Num(), 1); + if (Found.Num() == 1) + { + TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Core/GameModes/BP_FirstPersonGameMode"))); + } + }); + + It("finds the GameInstance from MenuSystemPro", [this]() + { + const TArray Found = Extract( + TEXT("GameInstanceClass=/Game/MenuSystemPro/Blueprints/GameFramework/BP_MenuSystemGameInstance.BP_MenuSystemGameInstance_C")); + + TestEqual(TEXT("one hit"), Found.Num(), 1); + if (Found.Num() == 1) + { + TestEqual(TEXT("package"), Found[0], + FString(TEXT("/Game/MenuSystemPro/Blueprints/GameFramework/BP_MenuSystemGameInstance"))); + } + }); + + It("finds a map reference with an object suffix", [this]() + { + const TArray Found = Extract(TEXT("EditorStartupMap=/Game/Space/Maps/Gyms/Gyms_Geoda.Gyms_Geoda")); + + TestEqual(TEXT("one hit"), Found.Num(), 1); + if (Found.Num() == 1) + { + TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Maps/Gyms/Gyms_Geoda"))); + } + }); + + It("finds a bare package path with no object suffix", [this]() + { + const TArray Found = Extract(TEXT("GameDefaultMap=/Game/Space/Maps/RefinedMenuMap")); + + TestEqual(TEXT("one hit"), Found.Num(), 1); + if (Found.Num() == 1) + { + TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Maps/RefinedMenuMap"))); + } + }); + + It("strips surrounding quotes and parentheses from an ini struct value", [this]() + { + const TArray Found = Extract(TEXT("+DirectoriesToAlwaysCook=(Path=\"/Game/Space/Art\")")); + + TestEqual(TEXT("one hit"), Found.Num(), 1); + if (Found.Num() == 1) + { + TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Art"))); + } + }); + + It("handles a C++ literal wrapped in TEXT()", [this]() + { + const TArray Found = Extract( + TEXT("static const FSoftObjectPath Path(TEXT(\"/Game/Space/Art/SM_Rock\"));")); + + TestEqual(TEXT("one hit"), Found.Num(), 1); + if (Found.Num() == 1) + { + TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Art/SM_Rock"))); + } + }); + + It("finds several distinct paths on one line", [this]() + { + const TArray Found = Extract( + TEXT("Redirect=(From=\"/Game/Old/SM_A\", To=\"/Game/New/SM_B\")")); + + TestEqual(TEXT("two hits"), Found.Num(), 2); + if (Found.Num() == 2) + { + TestEqual(TEXT("first"), Found[0], FString(TEXT("/Game/Old/SM_A"))); + TestEqual(TEXT("second"), Found[1], FString(TEXT("/Game/New/SM_B"))); + } + }); + + It("returns nothing for a line with no /Game path", [this]() + { + TestEqual(TEXT("no hits"), Extract(TEXT("bDynamicLevelStreaming=False")).Num(), 0); + TestEqual(TEXT("no hits"), Extract(TEXT("")).Num(), 0); + }); + + It("ignores a bare /Game root with no asset under it", [this]() + { + // "/Game" alone is a mount point, not a reference. Recording it would mark the entire + // project as referenced-from-config and make the verdict column meaningless. + TestEqual(TEXT("no hits"), Extract(TEXT("PackagePath=/Game")).Num(), 0); + }); + + It("does not match a path that merely contains the word Game", [this]() + { + TestEqual(TEXT("no hits"), Extract(TEXT("Path=/MiniGames2/Foo/SM_Bar")).Num(), 0); + }); + }); +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/AssetUsageAuditTests/Private/MigrateDestination.spec.cpp b/Source/AssetUsageAuditTests/Private/MigrateDestination.spec.cpp new file mode 100644 index 0000000..816cb03 --- /dev/null +++ b/Source/AssetUsageAuditTests/Private/MigrateDestination.spec.cpp @@ -0,0 +1,158 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetUsagePaths.h" + +#include "HAL/FileManager.h" +#include "Misc/App.h" +#include "Misc/AutomationTest.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" + +#if WITH_DEV_AUTOMATION_TESTS + +/** + * The Migrate destination check. + * + * Exists because the engine validates the destination only after MigratePackages has been called, + * and reports the refusal to the Output Log alone - so a bad folder presented as a button that did + * nothing at all. These specs pin the two rules to real folders on disk rather than to a reading of + * the engine source. + */ +BEGIN_DEFINE_SPEC(MigrateDestinationSpec, + "AssetUsageAudit.MigrateDestination", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + + FString ScratchDir; + + void MakeScratchDir(const TCHAR* Suffix) + { + ScratchDir = FPaths::ConvertRelativePathToFull( + FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("AssetUsageAuditTests"), Suffix)); + + IFileManager::Get().DeleteDirectory(*ScratchDir, false, true); + IFileManager::Get().MakeDirectory(*ScratchDir, true); + } + + void RemoveScratchDir() + { + if (!ScratchDir.IsEmpty()) + { + IFileManager::Get().DeleteDirectory(*ScratchDir, false, true); + } + } + + /** Build //Content plus a marker file beside it. */ + FString MakeFakeProject(const TCHAR* MarkerFileName) + { + const FString ProjectRoot = FPaths::Combine(ScratchDir, TEXT("FakeProject")); + const FString ContentDir = FPaths::Combine(ProjectRoot, TEXT("Content")); + + IFileManager::Get().MakeDirectory(*ContentDir, true); + + if (MarkerFileName) + { + FFileHelper::SaveStringToFile(TEXT("{}"), *FPaths::Combine(ProjectRoot, MarkerFileName)); + } + + return ContentDir; + } + +END_DEFINE_SPEC(MigrateDestinationSpec) + +void MigrateDestinationSpec::Define() +{ + Describe("Rejecting a destination Migrate would refuse", [this]() + { + AfterEach([this]() { RemoveScratchDir(); }); + + It("rejects an empty path", [this]() + { + TestFalse(TEXT("has a reason"), AssetUsagePaths::ValidateMigrateDestination(FString()).IsEmpty()); + }); + + It("rejects a folder that is not called Content", [this]() + { + // The project's own Saved/AssetUsageAudit is the default for every other layout, so + // this is the exact path a user arrives with after switching to Migrate. + MakeScratchDir(TEXT("MigrateNotContent")); + + const FString Reason = AssetUsagePaths::ValidateMigrateDestination(ScratchDir); + + TestFalse(TEXT("rejected"), Reason.IsEmpty()); + TestTrue(TEXT("says the folder must be Content"), Reason.Contains(TEXT("Content"))); + }); + + It("rejects a Content folder with no project or plugin beside it", [this]() + { + // Passes the name check and still aborts: Migrate derives the destination mount point + // from what sits next to the Content folder. + MakeScratchDir(TEXT("MigrateOrphanContent")); + + const FString ContentDir = MakeFakeProject(nullptr); + + TestFalse(TEXT("rejected"), AssetUsagePaths::ValidateMigrateDestination(ContentDir).IsEmpty()); + }); + + It("rejects a Content folder whose parent holds several plugins", [this]() + { + MakeScratchDir(TEXT("MigrateManyPlugins")); + + const FString ContentDir = MakeFakeProject(TEXT("First.uplugin")); + FFileHelper::SaveStringToFile(TEXT("{}"), + *FPaths::Combine(FPaths::GetPath(ContentDir), TEXT("Second.uplugin"))); + + const FString Reason = AssetUsagePaths::ValidateMigrateDestination(ContentDir); + + TestFalse(TEXT("rejected"), Reason.IsEmpty()); + TestTrue(TEXT("explains the ambiguity"), Reason.Contains(TEXT("several"))); + }); + }); + + Describe("Accepting a destination Migrate would use", [this]() + { + AfterEach([this]() { RemoveScratchDir(); }); + + It("accepts a Content folder next to a .uproject", [this]() + { + MakeScratchDir(TEXT("MigrateGoodProject")); + + const FString ContentDir = MakeFakeProject(TEXT("FakeProject.uproject")); + + TestTrue(TEXT("accepted"), AssetUsagePaths::ValidateMigrateDestination(ContentDir).IsEmpty()); + }); + + It("accepts a Content folder next to exactly one .uplugin", [this]() + { + // Plugin content is a legitimate migrate target; the engine derives the mount point + // from the plugin name. + MakeScratchDir(TEXT("MigrateGoodPlugin")); + + const FString ContentDir = MakeFakeProject(TEXT("Only.uplugin")); + + TestTrue(TEXT("accepted"), AssetUsagePaths::ValidateMigrateDestination(ContentDir).IsEmpty()); + }); + + It("accepts a trailing slash and backslashes alike", [this]() + { + // A path pasted from Explorer arrives with backslashes; one typed by hand often ends + // in a slash. Neither should read as "not a Content folder". + MakeScratchDir(TEXT("MigrateSlashes")); + + const FString ContentDir = MakeFakeProject(TEXT("FakeProject.uproject")); + + TestTrue(TEXT("trailing slash"), AssetUsagePaths::ValidateMigrateDestination(ContentDir + TEXT("/")).IsEmpty()); + TestTrue(TEXT("backslashes"), AssetUsagePaths::ValidateMigrateDestination(ContentDir.Replace(TEXT("/"), TEXT("\\"))).IsEmpty()); + }); + + It("accepts this project's own Content folder", [this]() + { + // The strongest available check that the rule matches reality: the running project is + // a real, valid migrate target by construction. + const FString ProjectContent = FPaths::ConvertRelativePathToFull(FPaths::ProjectContentDir()); + + TestTrue(TEXT("accepted"), AssetUsagePaths::ValidateMigrateDestination(ProjectContent).IsEmpty()); + }); + }); +} + +#endif // WITH_DEV_AUTOMATION_TESTS