From ea8fa30fdadba45d4170a3812763b1441a89a720 Mon Sep 17 00:00:00 2001 From: MagentaDolphin Date: Tue, 15 Sep 2026 16:35:21 +0700 Subject: [PATCH] docs: bilingual README (EN + RU) Full parallel English and Russian text for the plugin: what it does, install and requirements, quick start, interface, the five verdicts, settings, the three console commands, Blueprint/Python API, reports, export modes and manifests, import, guarded deletion, module architecture, the 16 Automation Spec suites (169 cases) and how to run them headless with -nullrhi, honest limitations, and the absence of a LICENSE file. --- README.md | 1037 +++++++++++++++++++++++++++++++++++------------------ 1 file changed, 686 insertions(+), 351 deletions(-) diff --git a/README.md b/README.md index bb82309..fa9169d 100644 --- a/README.md +++ b/README.md @@ -1,466 +1,801 @@ # Asset Usage Audit -Плагин редактора UE 5.6: показывает, какие ассеты используются на каких уровнях, и какие не используются нигде. Отмеченное галочками выгружается файлами или отчётом. +[English](#english) · [Русский](#русский) -Вырос из задачи сделать утилиты для UE по экспорту ассетов, используемых в локациях. +## English ---- +### What it is -## Быстрый старт +Asset Usage Audit is a source-code editor plugin for Unreal Engine 5.6. It answers two related questions: -1. `Window → Tools → Asset Usage Audit` (или в консоли `AssetUsageAudit.OpenPanel`) -2. Выбрать локацию в списке **Level** — это **область прогона**, а не фильтр результата -3. **Run Audit** -4. Отфильтровать по типу, отметить галочками -5. Внизу — **Export report** (JSON+CSV) или **Export ticked assets…**, открывающая окно выгрузки +- which project assets are used by which levels; +- which assets are not reached from any level. -Двойной клик по строке показывает ассет в Content Browser — как **Browse To** в редакторе. Ассет при этом не загружается: строка может быть картой или мешем на сотни мегабайт. +The audit is transitive: it walks from maps through Blueprints and other assets instead of looking only at direct references. The graph includes hard and soft package references, streaming sublevels and Level Instances, and One File Per Actor packages under `__ExternalActors__` and `__ExternalObjects__`. Every result receives one of five explicit verdicts rather than a misleading used/unused boolean. -Полный свип по всем 1072 уровням — ~1–3 с. По одному уровню — быстрее. +Selected rows can be written as a JSON/CSV report, copied as Unreal package files, converted through Unreal exporters to configured exchange formats, migrated into another Unreal project, or passed to Unreal's native deletion workflow after a safety review. ---- +The plugin is version `0.1`, is authored by MagentaDolphin, and contains no content of its own. -## Интерфейс +### Requirements and installation -### Тулбар +- Unreal Engine 5.6. +- Win64. Every module in `AssetUsageAudit.uplugin` has `PlatformAllowList: ["Win64"]`; no other platform is declared or claimed. +- A C++ Unreal project or another way to compile a source plugin. Prebuilt binaries are not stored in this repository. -| Контрол | Назначение | +Install it in a project: + +1. Close Unreal Editor so its plugin DLLs are not locked. +2. Place the repository at `MyGame/Plugins/AssetUsageAudit`. +3. Regenerate the project's IDE files if the project workflow requires it. +4. Build the project's Editor target for Win64 Development. +5. Open the project. The descriptor has `EnabledByDefault: true`; if the host project overrides plugin activation, enable **Asset Usage Audit** in the Plugins window and restart the editor. + +Example build command: + +```bat +"D:\Epic\UE_5.6\Engine\Build\BatchFiles\Build.bat" ^ + MyGameEditor Win64 Development ^ + -project="D:\Projects\MyGame\MyGame.uproject" -waitmutex +``` + +The repository can also be attached as a submodule: + +```bash +git submodule add https://git.kodlo.art/Kodlo/AssetUsageAudit Plugins/AssetUsageAudit +``` + +### Quick start + +1. Open `Window → Tools → Asset Usage Audit`, or run `AssetUsageAudit.OpenPanel` in the Unreal console. +2. In **Level**, select one or more maps. This controls the audit scope; an empty selection means all levels. +3. Press **Run Audit**. The first run after editor startup waits until the Asset Registry has finished loading, because a partial registry would produce a false unused list. +4. Narrow the displayed result with **Types**, **Gameplay class**, **Verdicts**, **References**, or the name/path search field. +5. Tick the required rows. The header checkbox ticks or clears all rows currently visible through the filters. +6. Press **Export report (JSON + CSV)** for a report of the current filtered view, or **Export ticked assets...** to choose a file export mode. + +Double-clicking an asset row browses to it in the Content Browser. The row is not loaded merely to locate it, which matters for large meshes and maps. + +### Interface + +#### Toolbar + +| Control | Purpose | |---|---| -| **By asset / By level** | Направление вопроса. `By level` строит **дерево**: уровни-заголовки, под ними ассеты. Без выбранного уровня показывает все уровни сразу — так сравниваются две локации. Галочка на заголовке отмечает всё под ним; частичный выбор рисуется третьим состоянием, а не полной галочкой | -| **Level** | Область аудита, **чеклист** с множественным выбором. Пусто = все уровни. Смена помечает результат устаревшим, но **не запускает прогон**: «All levels» это 1072 уровня | -| **Types** | Сверху **пресеты** (StaticMesh, Material, VFX, Sound…), ниже все 136 сырых классов. Пресет разворачивается в подклассы, поэтому `Material` ловит и `MaterialInstanceConstant`; пресеты, которых в проекте нет, не показываются. Повторный клик по выбранному пресету снимает его целиком | -| **Blueprint class** | Подстрока геймплейного класса. Непустое значение скрывает не-блюпринты | -| **Verdicts** | Пять состояний, все включены по умолчанию | -| **References** | Как ассет удерживается и через что достигнут | -| **Поиск** | По имени и пути | +| **Run Audit** | Builds the package graph and resolves level reachability. | +| **By asset / By level** | Changes the question without rerunning the audit. **By asset** lists assets and shows their levels. **By level** creates a tree with level headers and their assets; with all levels in scope, locations can be compared side by side. A level header ticks all children, and a partial child selection is shown as an indeterminate checkbox. | +| **Level** | Multi-select audit scope. No selected levels means all levels. Changing it marks the current result stale and does not start a potentially expensive run implicitly. | +| **Types** | Result filter. Presets are `StaticMesh`, `SkeletalMesh`, `Material`, `Texture`, `VFX`, `Sound`, `Blueprint`, `Level`, `DataAsset`, and `Animation`; raw asset classes follow. Presets expand through subclasses, so `Material` also catches material instances. Presets absent from the current project are not offered. Selecting an already fully selected preset clears it. | +| **Verdicts** | Result filter over all five verdicts. All verdicts are visible by default. | +| **References** | Result filter by incoming-edge strength (`Any`, `Hard only`, `Soft only`, `Mixed`, `No incoming edges`) and by required provenance flags: OFPA external actor, sublevel/Level Instance, editor-only edge, redirector, Config, and Source. Selected provenance flags are combined as requirements. | +| **Gameplay class** | Case-insensitive substring filter on a Blueprint's generated class. While non-empty it hides non-Blueprint rows. | +| **Search** | Case-insensitive filter by asset name or project-relative path. | -### Колонки +The distinction between scope and filters is intentional: + +- **Level** changes what is traversed. The old result becomes stale and **Run Audit** must be pressed again. +- **Types**, **Gameplay class**, **Verdicts**, **References**, and **Search** only change which rows from the completed run are displayed. They do not alter graph traversal. + +#### Columns `Check · Type · Name · Path · Verdict · Levels · Hard · Soft · Provenance · Route` -**Route** — цепочка, объясняющая вердикт: -``` -WP_Example → BP_Child_C_UAID_... → BP_Master → SM_Station -``` -Вердикт без маршрута для художника бесполезен, поэтому маршрут пишется всегда. +- **Type** is the asset class, such as `Blueprint`. A Blueprint's gameplay class is kept separately and appears in its tooltip. This prevents the type filter and the displayed type from describing different axes. +- **Levels** is the number of levels that reach the asset. +- **Hard** and **Soft** are incoming reference counts. Strength is recorded on graph edges; it is never used to narrow the dependency query. +- **Provenance** can contain `Hard`, `Soft`, `ExternalActor`, `Sublevel`, `Config`, `Source`, `EditorOnly`, and `Redirector`. +- **Route** is a human-readable chain explaining a level-reachable verdict, for example `WP_Example → BP_Child → BP_Master → SM_Station`. A route is evidence, not a reconstructed guess. -**Type** показывает класс ассета (`Blueprint`), геймплейный класс — в тултипе. Это сделано намеренно: если бы в колонке стоял `BP_Pickup_Child_C`, строка попадала бы под фильтр `Blueprint`, и таблица противоречила бы сама себе. +### Verdicts + +The exact `EAssetUsageVerdict` values are: + +| Verdict | Meaning | +|---|---| +| `UsedOnLevel` | At least one selected level reaches the asset. The levels, route, provenance, and hard/soft columns explain how. | +| `UsedByAssetsOnly` | The asset has referencers, but no chain from the audited levels reaches it. | +| `ReferencedFromConfigOrSource` | A literal `/Game/...` package path was found while scanning project `Config/` or `Source/`; provenance identifies the file and line. | +| `Unreferenced` | The graph has no incoming package reference, no audited level reaches the asset, and it is not classified as a known registry blind spot. This is evidence about the Asset Registry, not a deletion guarantee. | +| `Unknown` | The asset belongs to a known blind spot where absence of Asset Registry edges is not meaningful. It must never be presented as safe to delete. | + +#### `Unknown` is not “unused” + +The Asset Registry cannot prove every runtime use. In particular, it cannot reliably see: + +- paths assembled from strings in C++ or Blueprint; +- `OpenLevel(FName)` and comparable name-driven loading; +- plain path strings inside DataTable or CurveTable rows; consumers reference the whole table, not an independently traceable row; +- FMOD banks and events resolved by the FMOD runtime through string paths rather than UObject package edges. + +The code therefore treats FMOD packages/classes, `DataTable`, and `CurveTable` as blind spots. Positive evidence still wins: a blind-spot asset demonstrably reached from a level is `UsedOnLevel`. Without positive evidence, it becomes `Unknown`, not `Unreferenced`. + +When enabled, the indirect scanner supplements the registry by reading `.ini`, `.cpp`, `.h`, and `.cs` files under project `Config/` and `Source/`, extracting literal `/Game/...` paths, normalizing object/class suffixes, and recording file/line provenance. It cannot discover a path assembled dynamically at runtime. + +The audit does not automatically propose or perform deletion based on a verdict. Manual deletion is available for explicitly ticked rows, but it has a separate safety scan, two confirmation stages, and repeats the warning that neither `Unknown` nor `Unreferenced` proves runtime non-use. + +### Settings + +Team settings are under `Project Settings → your project category → Asset Usage Audit`. `UAssetUsageAuditSettings` uses `config=Editor, defaultconfig`, so values are stored in `Config/DefaultEditor.ini` and can be version-controlled. + +| Property | Default | Effect | +|---|---|---| +| `DefaultExportDirectory` | empty | Uses the absolute project `Saved/AssetUsageAudit` directory for reports and file export. | +| `bOverwriteExistingFiles` | `false` | The default collision policy is indexed names (`Foo_1`, `Foo_2`, and so on); `true` selects overwrite. | +| `ExchangeFormatByClass` | empty | Uses the built-in class-to-extension map. Lookup walks up the class hierarchy. | +| `ExportLayout` | `Flat` | Default file layout: `Flat`, `MirrorTree`, `FolderPerAsset`, or `Migrate`. | +| `bGroupDependenciesByType` | `false` | With `FolderPerAsset`, sorts dependencies into type-named subfolders; it has no effect on other layouts. | +| `ExcludedPackagePaths` | `Content/3rdParty`, `Content/StarterContent`, `Content/StarterBundle`, `Content/Megascans`, `Content/MSPresets` | Package/content-relative prefixes omitted from graph traversal and dependency expansion. | +| `IncludedPackagePaths` | empty | Empty means `/Game`; otherwise these roots define the graph input. | +| `bScanIndirectReferences` | `true` | Scans project Config and Source text files for literal `/Game` paths. | +| `bHideExternalPackages` | `true` | Hides OFPA plumbing rows while still traversing those packages. | + +Do not exclude purchased content blindly. A third-party package may provide a GameMode, GameInstance, startup map, or another class named only by configuration; excluding the root would remove that package from the graph before the indirect reference can make it actionable. + +The built-in conversion map is `StaticMesh → fbx`, `SkeletalMesh → fbx`, `AnimSequence → fbx`, `Texture2D → png`, `TextureCube → hdr`, `SoundWave → wav`, `DataTable → csv`, `CurveTable → csv`, `FontFace → ttf`, and `World → fbx`. An empty `ExchangeFormatByClass` selects this map. A custom map replaces it, and only entries backed by an engine `UExporter` can produce files. + +Per-user panel state is separate in `Saved/Config/.../EditorPerProjectUserSettings.ini`: selected levels, visible types and verdicts, generated-class filter, view mode, export mode, reference filters, and the include-dependencies choice. It is saved when the tab closes. The search text is deliberately not persisted, so the panel cannot reopen looking empty because of a forgotten search. + +The export dialog starts from team defaults plus the user's last mode/dependency choice, but canceling it writes nothing back. + +### Console commands + +The editor module registers exactly three console commands: + +| Command | Purpose | Example | +|---|---|---| +| `AssetUsageAudit.OpenPanel` | Opens the dockable audit panel. | `AssetUsageAudit.OpenPanel` | +| `AssetUsageAudit.Run` | Audits all levels and writes JSON and CSV reports; optional arguments restrict the run to level package names. | `AssetUsageAudit.Run /Game/Maps/WP_Example /Game/Maps/L_Interior` | +| `AssetUsageAudit.FindUnused` | Writes a report excluding `UsedOnLevel` rows. The remaining set still includes distinct `UsedByAssetsOnly`, `ReferencedFromConfigOrSource`, `Unreferenced`, and `Unknown` verdicts. | `AssetUsageAudit.FindUnused /Game/Maps/WP_Example` | + +With no level arguments, `Run` and `FindUnused` consider every level found in the configured graph roots. Both wait for the Asset Registry to finish scanning before they run. Reports use the configured output directory and a timestamped `AssetUsageReport_YYYYMMDD_HHMMSS` base name. + +### Blueprint and Python API + +`UAssetUsageAuditLibrary` is a `UBlueprintFunctionLibrary`; all four functions are `BlueprintCallable` in the **Asset Usage Audit** category and use the same settings and Core audit implementation as the panel. + +| Function | Result | +|---|---| +| `RunAudit(LevelPackageNames, OutputDirectory, OutReportPath)` | Runs an audit, writes JSON and CSV, returns success, and outputs the absolute JSON path. Empty levels mean all; an empty output directory uses the configured default. | +| `GetAssetsUsedOnLevel(LevelPackageName)` | Returns package names whose verdict is `UsedOnLevel` for that level. | +| `GetLevelsUsingAsset(AssetPackageName)` | Audits all levels and returns the levels that reach the package. A streamed sublevel asset can be attributed to both the sublevel and its parent. | +| `MeasureFullSweepSeconds()` | Runs a full sweep, logs its breakdown, and returns elapsed seconds. It is a measurement entry point, not a cached estimate. | + +In an Editor Utility Blueprint, add nodes from the **Asset Usage Audit** category and pass long package names such as `/Game/Maps/WP_Example`, not filesystem paths. + +The reflected Python names use Unreal's snake_case convention: + +```python +import unreal + +success, json_path = unreal.AssetUsageAuditLibrary.run_audit( + ["/Game/Maps/WP_Example"], + "D:/AuditOutput" +) + +assets = unreal.AssetUsageAuditLibrary.get_assets_used_on_level( + "/Game/Maps/WP_Example" +) +levels = unreal.AssetUsageAuditLibrary.get_levels_using_asset( + "/Game/Art/SM_Rock" +) +seconds = unreal.AssetUsageAuditLibrary.measure_full_sweep_seconds() +``` + +These calls are editor-facing: the library lives in `AssetUsageAuditEditor`, not in a runtime module. + +### Reports + +Every report write produces a pair from the same in-memory result: + +- JSON for tools and scripts; +- CSV for spreadsheets. + +The default destination is `Saved/AssetUsageAudit`. The panel asks for a directory and reports exactly the rows currently visible after UI filters; `RunAudit` and the console commands use their request/settings directly. + +The JSON contains: + +- a header with generation time, project, engine and tool versions, levels/assets/rows scanned, duration, and applied filters; +- counts for all five verdicts; +- graph statistics: packages, edges, levels, external packages, enumeration time, and dependency time; +- one object per asset with package/name/path/type, optional generated class, verdict, provenance, hard/soft counts, levels, and optional route/provenance detail. + +The CSV is UTF-8 with BOM so Excel preserves non-ASCII names. It uses `;` between fields and `|` inside multi-value fields. Its columns are: + +`Type · GeneratedClass · Name · PathFromProjectRoot · Verdict · LevelCount · Levels · HardRefs · SoftRefs · Provenance · Route · ProvenanceDetail` + +Comment lines above the header record generation metadata, filters, verdict counts, scan duration, and the warning that `Unknown` is not unused. Read the verdict together with provenance and route; a count without the path that produced it is not enough to make a content decision. + +### Exporting files + +Press **Export ticked assets...** after ticking rows. A modal window keeps the selected set stable while you choose the mode, destination, folder layout, dependency behavior, manifest, and collision policy. Canceling changes neither files nor persisted choices. + +#### Modes and layouts + +The two modes are: + +- **Copy `.uasset`**: byte-exact package copying without loading the assets. Exported maps also bring their discovered OFPA `.uasset` packages so the copied map is not empty. +- **Convert (FBX/PNG/WAV)**: loads each asset and runs `UAssetExportTask` with the configured extension. It performs garbage collection after every 64 loaded assets by default. + +The four mutually exclusive layouts are: + +| Layout | Behavior | +|---|---| +| `Flat` | Places every output file in one directory. Name collisions are handled by the selected policy. | +| `MirrorTree` | Recreates the package tree under the export root, for example `/Game/Art/SM_Rock` becomes `Game/Art/SM_Rock.uasset`. | +| `FolderPerAsset` | Creates one folder for each manually ticked asset and places its dependency closure beside it. A shared dependency is copied into every seed folder that needs it, so file placements can exceed distinct assets. | +| `Migrate` | Hands only the ticked packages to Unreal's `IAssetTools::MigratePackages`; Unreal builds the closure and restores packages at their original paths in another project. This is the only engine-managed option intended to preserve live Unreal references automatically. | + +With `FolderPerAsset`, **Sort dependencies into type subfolders** can place dependencies under preset-named folders such as `Texture`, `Material`, and `StaticMesh`; the seed remains at its folder root. The checkbox stays visible but disabled for layouts where it cannot apply. + +The collision policy is **Keep both** (indexed names) or **Overwrite**. For Migrate, renaming is impossible because changing a package path would defeat reference preservation; **Keep both** therefore maps to Unreal's **Skip** conflict policy. + +#### A copied `.uasset` does not carry its dependencies + +A package stores references as full package names such as `/Game/Art/T_Rock_D`, not as relative paths beside the copied file. Copying `SM_Rock.uasset` alone does not embed its materials or textures. Flat and per-asset layouts also change package placement, so their raw output is for review/handoff unless it is restored with path information. + +Use **Migrate** for a direct Unreal-to-Unreal transfer with engine-managed paths. Its destination must: + +1. end in a `Content` directory; +2. have either a `.uproject` or exactly one `.uplugin` one directory above it, allowing Unreal to derive the mount point. + +`AssetUsagePaths::ValidateMigrateDestination` checks both conditions before enabling export. Switching to Migrate clears a prefilled destination if it is invalid. `MigratePackages` returns `void` and reports its own result through Unreal notifications and the Output Log, so the panel does not invent a copied-file count. + +Migrate receives only the ticked packages; passing the plugin's already-expanded closure would make Unreal traverse redundant seeds. Disabling dependencies sets `bIgnoreDependencies`; Unreal then also omits a level's OFPA packages, so the panel asks for an additional confirmation because a migrated level can arrive empty. + +#### Include referenced assets + +**Include referenced assets** is on by default. For copy/convert layouts, the plugin computes each seed's transitive package dependency closure using the same package-category, no-requirements query as the audit. Before writing, the dialog shows the number of ticked seeds and actual file placements. + +Traversal boundaries are deliberate: + +- `/Script` is code and is never copied; +- `/Engine` and `/Temp` are excluded by default; +- project exclusion paths also bound dependency expansion; +- a manually ticked seed bypasses those path filters, because an explicit selection must still produce that file; +- a foreign map reached through an ordinary asset is not expanded; sublevels and Level Instances reached structurally from a level or external package are followed. + +This closure is not `MigratePackages`: it is bounded by the audit exclusions and leaves destination placement under plugin control. + +#### Dependency manifest + +For non-Migrate layouts, **Write dependency manifest** is on by default and writes `AssetUsageAudit.manifest.json` at the export root. It records schema/tool/project/engine metadata, layout, collision policy, and for every written file: + +- original package name; +- actual path relative to the export root after collision handling; +- asset/class hints when the registry provides them; +- whether the package was manually selected (`seed`) or arrived as a dependency; +- its direct package dependencies, including the relative file when that dependency is present in the same export. + +The manifest is built from the exporter's recorded actual paths, not planned names. This matters precisely when a collision causes indexing. An empty recorded-file list is treated as an error rather than writing a plausible but false empty manifest. Migrate refuses manifest generation because it already preserves package paths. + +#### Raw copies and conversion + +Raw copy removes a read-only attribute from an existing destination before overwrite and from the copied file afterward. This prevents a read-only source attribute from making an exported handoff immutable or blocking the next overwrite. + +Conversion uses the class-to-extension map and searches up the class hierarchy. A `Texture` mapping can therefore cover subclasses; only classes for which Unreal has an appropriate `UExporter` produce output. Blueprint has no built-in mapping by design. Materials, Blueprints, and Niagara assets have no default exchange format here. + +Skipped conversions are reported separately as **no configured format**, **asset failed to load**, or **no exporter for that format**. Conversion loads assets and can be materially slower than byte copying; the repository makes no general performance promise for large batches. + +### Importing from an external folder + +Open `Window → Tools → Import Assets from Folder...`. This workflow recursively scans `.uasset` and `.umap` files without loading them, lists candidates, and lets you choose what to copy back into the current project. + +The table shows the source file, target package, where that target came from, dependency status, and whether the target already exists. Target recovery uses four sources in descending confidence: + +| Source | Meaning | +|---|---| +| `Manifest` | Exact original package and actual exported filename from `AssetUsageAudit.manifest.json`. | +| `Package header` | `FPackageFileSummary::PackageName`, when the package serialized a valid name. Unreal itself permits this field to be absent or `None`, so fallback is required. | +| `Folder structure` | Inferred from the file's location under the scan root. Exact for the plugin's mirrored layout and only a guess for other folders. | +| `Unresolved` | No target could be recovered. A user-supplied `/Game/...` fallback directory is required, and original references may remain broken. | + +When a manifest is present, the scanner also reports dependencies that are neither in the import folder nor already registered in the project. + +Import safety rules are strict because overwrite has no undo or backup: + +- an existing target is skipped unless **Overwrite existing assets** is enabled explicitly; +- if selected rows would be overwritten, a separate confirmation states the count and consequence; +- a package already loaded in the editor is never overwritten, even when overwrite is enabled, because stale in-memory objects could later save over the imported file; +- an unresolved file is skipped unless a valid fallback package directory is supplied; +- invalid target paths and failed copies are reported individually. + +After successful copies, `ScanFilesSynchronous(..., bForceRescan=true)` makes them visible to the Content Browser. The dialog rescans afterward, so newly imported targets appear as already present instead of inviting a duplicate second import. + +### Deleting ticked assets + +**Delete ticked assets...** acts on exactly the rows selected in the panel. It never expands the set with dependencies: expansion is helpful for export, but during deletion a shared texture could otherwise remove content nobody selected. + +Deletion passes through two windows: + +1. The plugin's review window lists every requested package with name, type, path, outside referencer count, and status. Deletable rows start checked. Referencers that are themselves in the delete set are excluded from the outside count; up to eight names are shown in the tooltip, while the count remains exact. Map/OFPA referencers are also identified as level references, and read-only files are flagged before deletion. The Delete button stays disabled until **I understand these files will be removed from the project** is checked. +2. The confirmed set is resolved again to fresh `FAssetData` and passed to `ObjectTools::DeleteAssets(..., bShowConfirmation=true)`. Unreal's native **Delete Assets** window then owns the final reference list, **Force Delete**, and **Replace References** behavior. + +The plugin refuses these packages before the native window: + +| Refusal | Reason | +|---|---| +| Level (`.umap`) | A level is the unit against which the audit measures usage; deleting it invalidates the rest of the result. Unreal also treats an open map specially. Delete maps deliberately from the Content Browser. | +| OFPA package under `__ExternalActors__` or `__ExternalObjects__` | It represents a placed actor/object belonging to a map. Remove it in the level editor, where the operation participates in the level workflow and undo. | +| `/Engine`, `/Script`, or `/Temp` | It is not deletable project content for this tool. | +| Missing package | The registry no longer has an asset under that package name. | + +An outside referencer or read-only flag is a warning, not a silent omission: Unreal's native window makes the final decision. Source control is the recovery mechanism; the plugin creates no backup and offers no undo. + +After deletion, removed rows disappear, but other rows retain reference counts and verdicts from the old graph. The panel marks the result stale and asks for another audit. `Unreferenced` still does not prove absence of dynamic string, DataTable-row, or FMOD use, and the deletion window repeats that warning. + +### Architecture + +| Module | Type | Responsibility | +|---|---|---| +| `AssetUsageAuditCore` | `UncookedOnly`, `Default` | Graph construction, transitive level resolution, OFPA handling, verdicts, indirect scan, report writer, dependency closure, file export/layout/manifest, import scan/copy, and pre-delete analysis. No Slate UI or editor asset pipeline. | +| `AssetUsageAuditEditor` | `Editor`, `PostEngineInit` | Dockable Slate panel, toolbar/menu registration, settings, console commands, Blueprint/Python library, export/import/delete dialogs, Migrate dispatch, and native deletion handoff. | +| `AssetUsageAuditTests` | `UncookedOnly`, `Default` | Automation Specs for Core behavior plus editor-context/live coverage, including Migrate. | + +All three descriptor entries are Win64-only. `AssetUsageAuditCore` publicly depends on `Core`, `CoreUObject`, and `AssetRegistry`, and privately on runtime `Engine`, `Json`, and `Projects`. Its invariant is to remain free of `UnrealEd`, `AssetTools`, `ToolMenus`, `Slate`, `SlateCore`, and editor subsystems. `Engine` is permitted because `ULevel::GetExternalActorsPaths`, `GetExternalObjectsPaths`, and level asset scanning are runtime APIs available to commandlet-oriented code. + +#### Correctness invariants + +1. **Dependency traversal uses package category with `FDependencyQuery{}` (`NoRequirements`).** A hard-only query loses soft references and OFPA edges. `Soft` is a negated hard requirement, so combining required Hard and Soft is not “either kind.” Hard/soft remains report data, never a traversal filter. +2. **A map boundary is crossed only from a level already in the result or from one of its external packages.** This admits streaming sublevels and Level Instances without pulling an unrelated map and all of its content through an ordinary data/Blueprint reference. The internal `bTraverseIntoOtherLevels` option exists for callers that explicitly need the broader behavior. +3. **OFPA roots are obtained from `ULevel::GetExternalActorsPaths` and `GetExternalObjectsPaths`, never assembled as strings.** Content Bundles, External Data Layers, and plugin delegates can add path structure. Resolution proceeds forward from the map; it does not invent an external-package-to-map reverse edge. +4. **Blueprint asset class and generated gameplay class are separate.** A Blueprint asset's class is `/Script/Engine.Blueprint`; gameplay filtering uses the `GeneratedClass` registry tag and subclass expansion. +5. **`ScanLevelAssets` runs before level traversal by default.** Otherwise an unopened OFPA level can look empty because its external packages have not entered the registry yet. + +The implementation keeps checkbox state in package-name sets rather than `SListView` selection, so filtering does not erase a user's ticked set. Scope changes do not auto-run. Reports and all public entry points call the same Core auditor. Conversion remains in Core because `UExporter` and `UAssetExportTask` are Engine APIs; concrete exporters are resolved at runtime and an unavailable exporter becomes an explicit skip reason. Migrate stays in Editor because `AssetTools` is editor-only. + +### Tests + +`Source/AssetUsageAuditTests/Private` contains 16 `*.spec.cpp` Automation Spec suites. Counting the `It(...)` definitions in those files gives 169 cases. The test prefix is `AssetUsageAudit`. + +Build the editor target first, then run from a Windows command prompt: + +```bat +"D:\Epic\UE_5.6\Engine\Binaries\Win64\UnrealEditor-Cmd.exe" ^ + "D:\Projects\MyGame\MyGame.uproject" ^ + -ExecCmds="Automation RunTests AssetUsageAudit" ^ + -TestExit="Automation Test Queue Empty" ^ + -unattended -nopause -nosplash -stdout ^ + -abslog="D:\Projects\MyGame\Saved\Logs\AssetUsageAuditTests.log" +``` + +For a machine without a desktop/GPU session, add `-nullrhi` so Unreal does not initialize a graphics device: + +```bat +"D:\Epic\UE_5.6\Engine\Binaries\Win64\UnrealEditor-Cmd.exe" ^ + "D:\Projects\MyGame\MyGame.uproject" ^ + -ExecCmds="Automation RunTests AssetUsageAudit" ^ + -TestExit="Automation Test Queue Empty" ^ + -unattended -nopause -nosplash -stdout -nullrhi ^ + -abslog="D:\Projects\MyGame\Saved\Logs\AssetUsageAuditTests.log" +``` + +Some suites deliberately inspect real host-project content and skip individual checks with warnings if no suitable subject exists. In particular, `AssetUsageAudit.ExporterLive`, `AssetUsageAudit.ExchangeExport`, `AssetUsageAudit.GraphFidelity`, and `AssetUsageAudit.MigrateLive` search the Asset Registry for usable project assets/levels rather than relying on fixed package names. Review warnings to distinguish a genuinely exercised live check from a skipped one. + +`GraphFidelity` compares a stable package-name sample of the plugin graph with raw Asset Registry answers: missing/extra edges, hard classification by target, real route transitions, the OFPA soft-edge invariant, and sublevel attribution. Comparing by package name avoids registry enumeration-order instability; hard state is compared per target because Unreal may return the same target through both hard and soft edges. + +The previous host project recorded an unrelated `TNotNull` fatal with `-nullrhi` even when all plugins were disabled, and a missing default-material shader map with `-NoShaderCompile`. Those are host-project constraints, not successful flags for that project. The plugin repository contains no `.uproject`, so executing the suite always requires a UE 5.6 host project with the plugin installed. + +Tests that intentionally emit `UE_LOG(Error)` declare the message with `AddExpectedError`; otherwise Unreal Automation correctly treats the log error as a failed test. + +### Limitations + +- This is an editor/source plugin, not a runtime gameplay system. It does not ship an in-game UI or runtime audit. +- The descriptor declares only Win64. Other platforms are neither packaged nor supported by the repository metadata. +- The repository has no host `.uproject`, prebuilt DLLs, commandlet implementation, badges, screenshots, or external documentation pages. +- Asset Registry evidence cannot cover dynamically constructed paths, name-driven level loads, arbitrary DataTable/CurveTable row strings, or FMOD's string-addressed events. `Unknown` and even `Unreferenced` require human review. +- The Config/Source scan finds literal `/Game` paths in supported text files; it is not a C++ or Blueprint data-flow analyzer. +- Flat and per-asset package copies do not rewrite internal package references. Use the manifest/import workflow for restoration, or Unreal Migrate for direct project transfer. +- Conversion depends on installed/registered Unreal exporters. Classes without a configured format are skipped; a configured extension without an exporter is reported separately. Large conversion batches load assets, and repository tests cover individual exports rather than a published large-volume performance target. +- Delete and overwrite operations have no plugin-level undo or backup. They rely on explicit confirmation and source control. +- Slate interaction is not covered by the Automation Specs; UI behavior should be smoke-tested in the editor after UI changes. +- Live suites can warn and skip when a host project has no suitable real content. A green run should be reviewed together with those warnings. +- The Core architecture is commandlet-oriented, but this repository does not provide a dedicated audit commandlet. + +### License + +This repository contains no `LICENSE` file. The source is published for portfolio demonstration and review; licensing terms for reuse, redistribution, or incorporation into another project must be requested from the author. No third-party license is implied here. + +### Links + +- Repository: https://git.kodlo.art/Kodlo/AssetUsageAudit +- Author: Kodlo (MagentaDolphin) --- -## Вердикты +## Русский -Пять состояний, **никогда не булево**. +### Что это + +Asset Usage Audit — редакторный плагин с исходным кодом для Unreal Engine 5.6. Он отвечает на два связанных вопроса: + +- какие ассеты проекта используются на каких уровнях; +- какие ассеты не достижимы ни от одного уровня. + +Аудит транзитивный: он идёт от карт через Blueprints и другие ассеты, а не ограничивается прямыми ссылками. В граф входят жёсткие и мягкие пакетные ссылки, стриминговые подуровни и Level Instances, а также пакеты One File Per Actor из `__ExternalActors__` и `__ExternalObjects__`. Каждая строка получает один из пяти явных вердиктов вместо обманчивого булева «используется / не используется». + +Отмеченные строки можно сохранить в отчёт JSON/CSV, скопировать как пакетные файлы Unreal, преобразовать штатными экспортёрами Unreal в настроенные обменные форматы, перенести в другой Unreal-проект через Migrate или передать в штатный сценарий удаления Unreal после отдельной проверки безопасности. + +Версия плагина — `0.1`, автор — MagentaDolphin; собственного контента в плагине нет. + +### Требования и установка + +- Unreal Engine 5.6. +- Win64. У каждого модуля в `AssetUsageAudit.uplugin` задан `PlatformAllowList: ["Win64"]`; другие платформы не заявлены. +- C++-проект Unreal либо другой способ собрать плагин из исходников. Готовые бинарники в репозитории не хранятся. + +Установка в проект: + +1. Закройте Unreal Editor, чтобы DLL плагина не были заблокированы. +2. Поместите репозиторий в `MyGame/Plugins/AssetUsageAudit`. +3. Если этого требует принятый в проекте процесс, перегенерируйте файлы IDE. +4. Соберите Editor-таргет проекта в конфигурации Win64 Development. +5. Откройте проект. В дескрипторе стоит `EnabledByDefault: true`; если проект переопределяет активацию плагинов, включите **Asset Usage Audit** в окне Plugins и перезапустите редактор. + +Пример команды сборки: + +```bat +"D:\Epic\UE_5.6\Engine\Build\BatchFiles\Build.bat" ^ + MyGameEditor Win64 Development ^ + -project="D:\Projects\MyGame\MyGame.uproject" -waitmutex +``` + +Репозиторий можно подключить как сабмодуль: + +```bash +git submodule add https://git.kodlo.art/Kodlo/AssetUsageAudit Plugins/AssetUsageAudit +``` + +### Быстрый старт + +1. Откройте `Window → Tools → Asset Usage Audit` либо выполните в консоли Unreal `AssetUsageAudit.OpenPanel`. +2. В **Level** выберите одну или несколько карт. Это область аудита; пустой выбор означает все уровни. +3. Нажмите **Run Audit**. Первый прогон после запуска редактора ждёт окончания загрузки Asset Registry: неполный реестр дал бы ложный список неиспользуемого. +4. Сузьте показанный результат через **Types**, **Gameplay class**, **Verdicts**, **References** или поиск по имени и пути. +5. Отметьте нужные строки. Галочка в заголовке отмечает или очищает все строки, которые сейчас видны после фильтров. +6. Нажмите **Export report (JSON + CSV)** для отчёта по текущему отфильтрованному виду либо **Export ticked assets...**, чтобы выбрать режим выгрузки файлов. + +Двойной клик по строке показывает ассет в Content Browser. Ради навигации ассет не загружается — это важно для тяжёлых мешей и карт. + +### Интерфейс + +#### Тулбар + +| Контрол | Назначение | +|---|---| +| **Run Audit** | Строит пакетный граф и вычисляет достижимость от уровней. | +| **By asset / By level** | Меняет представление без повторного аудита. **By asset** перечисляет ассеты и показывает их уровни. **By level** строит дерево из заголовков-уровней и их ассетов; при области «все уровни» локации можно сравнивать рядом. Галочка заголовка отмечает всех потомков, частичный выбор показан третьим состоянием. | +| **Level** | Множественный выбор области аудита. Пусто означает все уровни. Изменение помечает текущий результат устаревшим и не запускает потенциально долгий прогон само. | +| **Types** | Фильтр результата. Сначала идут пресеты `StaticMesh`, `SkeletalMesh`, `Material`, `Texture`, `VFX`, `Sound`, `Blueprint`, `Level`, `DataAsset`, `Animation`, затем сырые классы ассетов. Пресеты раскрываются по подклассам, поэтому `Material` включает material instances. Отсутствующие в проекте пресеты не показываются. Клик по уже полностью выбранному пресету снимает его целиком. | +| **Verdicts** | Фильтр результата по пяти вердиктам. По умолчанию видны все. | +| **References** | Фильтр результата по силе входящих рёбер (`Any`, `Hard only`, `Soft only`, `Mixed`, `No incoming edges`) и обязательным признакам происхождения: внешний актор OFPA, подуровень/Level Instance, editor-only ребро, redirector, Config, Source. Выбранные признаки применяются совместно. | +| **Gameplay class** | Регистронезависимый поиск подстроки в сгенерированном классе Blueprint. Непустое поле скрывает строки не-Blueprint. | +| **Search** | Регистронезависимый фильтр по имени ассета и пути относительно проекта. | + +Разница области и фильтров принципиальна: + +- **Level** меняет сам обход. Старый результат становится устаревшим, после чего нужно снова нажать **Run Audit**. +- **Types**, **Gameplay class**, **Verdicts**, **References** и **Search** лишь скрывают строки готового прогона. Граф они не меняют. + +#### Колонки + +`Check · Type · Name · Path · Verdict · Levels · Hard · Soft · Provenance · Route` + +- **Type** показывает класс ассета, например `Blueprint`. Геймплейный класс Blueprint хранится отдельно и показан в тултипе. Так отображение и фильтр типов не смешивают две разные оси. +- **Levels** — число уровней, от которых достижим ассет. +- **Hard** и **Soft** — счётчики входящих ссылок. Сила хранится на рёбрах графа и никогда не сужает запрос зависимостей. +- **Provenance** может содержать `Hard`, `Soft`, `ExternalActor`, `Sublevel`, `Config`, `Source`, `EditorOnly`, `Redirector`. +- **Route** — читаемая цепочка, объясняющая достижимость от уровня, например `WP_Example → BP_Child → BP_Master → SM_Station`. Это цепочка реальных рёбер, а не догадка, собранная после прогона. + +### Вердикты + +Точный список значений `EAssetUsageVerdict`: | Вердикт | Значение | |---|---| -| `UsedOnLevel` | Достижим от карты. Колонки уточняют hard/soft и через какой уровень | -| `UsedByAssetsOnly` | Есть референсеры, но ни одна цепочка не доходит до карты | -| `ReferencedFromConfigOrSource` | Найден grep-ом по `Config/` и `Source/`. В провенансе — файл и строка | -| `Unreferenced` | Ни одной входящей ссылки | -| `Unknown` | Попадает в слепую зону реестра | +| `UsedOnLevel` | Ассет достижим хотя бы от одного выбранного уровня. Колонки уровней, маршрута, происхождения и hard/soft объясняют, как именно. | +| `UsedByAssetsOnly` | У ассета есть референсеры, но ни одна цепочка от проверяемых уровней до него не дошла. | +| `ReferencedFromConfigOrSource` | При сканировании проектных `Config/` или `Source/` найден буквальный пакетный путь `/Game/...`; в происхождении указаны файл и строка. | +| `Unreferenced` | У графа нет входящей пакетной ссылки, ни один проверенный уровень не достигает ассета, а сам ассет не относится к известной слепой зоне реестра. Это свидетельство Asset Registry, но не гарантия безопасного удаления. | +| `Unknown` | Ассет относится к известной слепой зоне, где отсутствие рёбер Asset Registry ничего не доказывает. Такой результат нельзя выдавать за разрешение на удаление. | -### ⚠️ `Unknown` — это не «не используется» +#### `Unknown` — это не «не используется» -Asset Registry принципиально не видит: +Asset Registry не может доказать все рантайм-использования. В частности, он ненадёжно видит или совсем не видит: -- пути, собранные конкатенацией строк в C++/BP; -- `OpenLevel(FName)`; -- строки внутри DataTable — уровень зависит от таблицы целиком, поэтому любая строка выглядит используемой; -- **FMOD** — резолвит события строковыми путями мимо UObject-графа. Аудио систематически попадает в `Unknown`. +- пути, собранные из строк в C++ или Blueprint; +- `OpenLevel(FName)` и похожую загрузку по имени; +- обычные строки-пути внутри строк DataTable или CurveTable: потребитель ссылается на таблицу целиком, а не на отдельно отслеживаемую строку; +- банки и события FMOD, которые рантайм FMOD разрешает по строковым путям вне пакетного UObject-графа. -**Инструмент никогда не удаляет и не предлагает удалить.** Только отбор и выгрузка. +Поэтому код считает слепыми зонами пакеты/классы FMOD, `DataTable` и `CurveTable`. Положительное свидетельство важнее: если такой ассет явно достижим от уровня, его вердикт — `UsedOnLevel`. При отсутствии положительного свидетельства получается `Unknown`, а не `Unreferenced`. -Смягчение: скан `Config/` и `Source/` по `\/Game([A-Za-z0-9_.\/]+)\b`. Без него GameMode, GameInstance и стартовая карта помечались бы мусором — на них не ссылается ни один ассет, только `DefaultEngine.ini`. +Если включён косвенный скан, плагин дополняет реестр: читает `.ini`, `.cpp`, `.h`, `.cs` в проектных `Config/` и `Source/`, извлекает буквальные пути `/Game/...`, нормализует суффиксы объектов/классов и сохраняет происхождение в виде файла и номера строки. Динамически собранный рантайм-путь так найти нельзя. ---- +Сам аудит не предлагает и не запускает удаление автоматически по вердикту. Вручную удалить явно отмеченные строки можно, но для этого есть отдельный анализ безопасности, два этапа подтверждения и повторное предупреждение: ни `Unknown`, ни `Unreferenced` не доказывают отсутствие рантайм-использования. -## Настройки +### Настройки -`Project Settings → <Проект> → Asset Usage Audit`. Пишутся в `Config/DefaultEditor.ini` — файл под контролем версий, настройки общие для команды. +Командные настройки находятся в `Project Settings → категория с именем проекта → Asset Usage Audit`. У `UAssetUsageAuditSettings` задано `config=Editor, defaultconfig`, поэтому значения попадают в `Config/DefaultEditor.ini` и могут храниться под контролем версий. -Состояние панели (выбранный уровень, типы, режим выгрузки, чекбокс зависимостей) хранится **отдельно** — в `Saved/Config/.../EditorPerProjectUserSettings.ini`, пер-юзерно и вне контроля версий. Сохраняется при закрытии вкладки; аварийное завершение редактора теряет изменения. Строка поиска намеренно не восстанавливается: панель, открывшаяся пустой из-за забытого фильтра, выглядит сломанной. +| Параметр | По умолчанию | Что делает | +|---|---|---| +| `DefaultExportDirectory` | пусто | Для отчётов и файлов используется абсолютный проектный путь `Saved/AssetUsageAudit`. | +| `bOverwriteExistingFiles` | `false` | По умолчанию коллизии разрешаются индексированием (`Foo_1`, `Foo_2` и далее); `true` выбирает перезапись. | +| `ExchangeFormatByClass` | пусто | Используется встроенная карта «класс → расширение». Поиск идёт вверх по иерархии классов. | +| `ExportLayout` | `Flat` | Раскладка по умолчанию: `Flat`, `MirrorTree`, `FolderPerAsset` или `Migrate`. | +| `bGroupDependenciesByType` | `false` | При `FolderPerAsset` раскладывает зависимости по подпапкам типов; для других раскладок не действует. | +| `ExcludedPackagePaths` | `Content/3rdParty`, `Content/StarterContent`, `Content/StarterBundle`, `Content/Megascans`, `Content/MSPresets` | Префиксы пакетов/контента, исключённые из графа и раскрытия зависимостей. | +| `IncludedPackagePaths` | пусто | Пусто означает `/Game`; иначе эти корни задают вход графа. | +| `bScanIndirectReferences` | `true` | Ищет буквальные пути `/Game` в текстовых файлах проектных Config и Source. | +| `bHideExternalPackages` | `true` | Скрывает служебные строки OFPA, но продолжает обходить эти пакеты. | -| Параметр | По умолчанию | +Не исключайте покупной контент вслепую. Сторонний пакет может содержать GameMode, GameInstance, стартовую карту или другой класс, названный только конфигурацией; исключение корня уберёт пакет из графа раньше, чем косвенная ссылка сможет сделать его видимым в отчёте. + +Встроенная карта конвертации: `StaticMesh → fbx`, `SkeletalMesh → fbx`, `AnimSequence → fbx`, `Texture2D → png`, `TextureCube → hdr`, `SoundWave → wav`, `DataTable → csv`, `CurveTable → csv`, `FontFace → ttf`, `World → fbx`. Пустой `ExchangeFormatByClass` включает именно её. Пользовательская карта заменяет встроенную; файл получится только для сочетания, под которое движок зарегистрировал `UExporter`. + +Персональное состояние панели хранится отдельно в `Saved/Config/.../EditorPerProjectUserSettings.ini`: выбранные уровни, видимые типы и вердикты, фильтр generated class, режим представления, режим выгрузки, фильтры ссылок и выбор зависимостей. Оно сохраняется при закрытии вкладки. Поисковая строка намеренно не восстанавливается, чтобы панель не открывалась пустой из-за забытого поиска. + +Окно выгрузки начинает с командных умолчаний и последних персональных значений режима/зависимостей, но отмена ничего не записывает обратно. + +### Консольные команды + +Редакторный модуль регистрирует ровно три команды: + +| Команда | Назначение | Пример | +|---|---|---| +| `AssetUsageAudit.OpenPanel` | Открывает докируемую панель аудита. | `AssetUsageAudit.OpenPanel` | +| `AssetUsageAudit.Run` | Проверяет все уровни и пишет JSON и CSV; необязательные аргументы ограничивают прогон пакетными именами уровней. | `AssetUsageAudit.Run /Game/Maps/WP_Example /Game/Maps/L_Interior` | +| `AssetUsageAudit.FindUnused` | Пишет отчёт без строк `UsedOnLevel`. В оставшемся наборе по-прежнему различаются `UsedByAssetsOnly`, `ReferencedFromConfigOrSource`, `Unreferenced`, `Unknown`. | `AssetUsageAudit.FindUnused /Game/Maps/WP_Example` | + +Без аргументов уровней `Run` и `FindUnused` рассматривают все уровни, найденные в настроенных корнях графа. Обе команды ждут окончания загрузки Asset Registry. Отчёты используют настроенную папку и имя вида `AssetUsageReport_YYYYMMDD_HHMMSS`. + +### Blueprint и Python API + +`UAssetUsageAuditLibrary` наследует `UBlueprintFunctionLibrary`; все четыре функции имеют `BlueprintCallable`, категорию **Asset Usage Audit** и используют те же настройки и реализацию Core, что и панель. + +| Функция | Результат | |---|---| -| `DefaultExportDirectory` | пусто → `Saved/AssetUsageAudit` | -| `bOverwriteExistingFiles` | `false` → индексирование имён | -| `ExcludedPackagePaths` | `3rdParty`, `StarterContent`, `StarterBundle`, `Megascans`, `MSPresets` | -| `IncludedPackagePaths` | пусто → `/Game` | -| `bScanIndirectReferences` | `true` | -| `bHideExternalPackages` | `true` | -| `bMirrorFolderStructure` | `false` → плоская папка | -| `ExchangeFormatByClass` | пусто → встроенные умолчания | +| `RunAudit(LevelPackageNames, OutputDirectory, OutReportPath)` | Запускает аудит, пишет JSON и CSV, возвращает признак успеха и абсолютный путь JSON. Пустой список уровней означает все, пустая выходная папка — настроенное умолчание. | +| `GetAssetsUsedOnLevel(LevelPackageName)` | Возвращает пакетные имена строк с вердиктом `UsedOnLevel` для заданного уровня. | +| `GetLevelsUsingAsset(AssetPackageName)` | Проверяет все уровни и возвращает те, от которых достижим пакет. Ассет стримингового подуровня может быть приписан и подуровню, и родительской карте. | +| `MeasureFullSweepSeconds()` | Выполняет полный прогон, пишет разбивку в лог и возвращает прошедшие секунды. Это точка реального измерения, а не оценка из кэша. | -⚠️ Покупной пак **не следует исключать вслепую**: он может поставлять класс, который `DefaultEngine.ini` назначает GameMode или GameInstance проекта. Проверяйте `ExcludedPackagePaths` против конфига. +В Editor Utility Blueprint добавляйте ноды категории **Asset Usage Audit** и передавайте длинные пакетные имена вроде `/Game/Maps/WP_Example`, а не файловые пути. ---- +В Python отражённые имена следуют принятому в Unreal `snake_case`: -## Консольные команды +```python +import unreal -``` -AssetUsageAudit.OpenPanel -AssetUsageAudit.Run [/Game/Maps/YourLevel ...] -AssetUsageAudit.FindUnused +success, json_path = unreal.AssetUsageAuditLibrary.run_audit( + ["/Game/Maps/WP_Example"], + "D:/AuditOutput" +) + +assets = unreal.AssetUsageAuditLibrary.get_assets_used_on_level( + "/Game/Maps/WP_Example" +) +levels = unreal.AssetUsageAuditLibrary.get_levels_using_asset( + "/Game/Art/SM_Rock" +) +seconds = unreal.AssetUsageAuditLibrary.measure_full_sweep_seconds() ``` -Без аргументов `Run` обходит все уровни. Первый вызов после старта редактора ждёт догрузки Asset Registry. +Эти вызовы относятся к редактору: библиотека находится в `AssetUsageAuditEditor`, а не в рантайм-модуле. -## Blueprint / Python +### Отчёты -Категория `Asset Usage Audit`: `RunAudit`, `GetAssetsUsedOnLevel`, `GetLevelsUsingAsset`, `MeasureFullSweepSeconds`. +Каждая запись отчёта создаёт пару файлов из одного результата в памяти: ---- +- JSON — для инструментов и скриптов; +- CSV — для таблиц. -## Отчёты +Папка по умолчанию — `Saved/AssetUsageAudit`. Панель предлагает выбрать директорию и сохраняет ровно строки текущего вида после UI-фильтров; `RunAudit` и консольные команды используют свои запросы и настройки напрямую. -Пишутся парой, `Saved/AssetUsageAudit/`: +В JSON входят: -- **JSON** — вложенные списки уровней, метаданные фильтров -- **CSV** — UTF-8 **с BOM**, разделитель `;`, многозначные поля через `|` +- заголовок со временем генерации, проектом, версиями движка и инструмента, числами уровней/ассетов/строк, длительностью и применёнными фильтрами; +- счётчики всех пяти вердиктов; +- статистика графа: пакеты, рёбра, уровни, внешние пакеты, время перечисления и время получения зависимостей; +- объект каждого ассета с пакетом, именем, путём, типом, необязательным generated class, вердиктом, происхождением, hard/soft-счётчиками, уровнями и необязательными маршрутом/деталями происхождения. -BOM обязателен: без него Excel ломает кириллицу. Шапка CSV содержит применённые фильтры, версию движка, длительность и предупреждение про `Unknown`. +CSV записывается в UTF-8 с BOM, чтобы Excel не ломал не-ASCII имена. Разделитель полей — `;`, значений внутри многозначного поля — `|`. Колонки: ---- +`Type · GeneratedClass · Name · PathFromProjectRoot · Verdict · LevelCount · Levels · HardRefs · SoftRefs · Provenance · Route · ProvenanceDetail` -## Выгрузка файлов +Строки-комментарии перед шапкой содержат метаданные генерации, фильтры, счётчики вердиктов, длительность и предупреждение, что `Unknown` не равен «не используется». Вердикт нужно читать вместе с происхождением и маршрутом: одного числа без объясняющей цепочки недостаточно для решения о контенте. -Все параметры собраны в отдельном окне: кнопка **Export ticked assets…** внизу панели. Наверху остались только фильтры и **Run Audit** — режим, раскладка, папка и зависимости трогаются лишь в момент выгрузки, и окно может показать, к чему они приведут. +### Выгрузка файлов -Окно наследует значения из настроек проекта и из прошлого выбора этого пользователя, но **ничего не пишет обратно при отмене**. +После выбора строк нажмите **Export ticked assets...**. Модальное окно фиксирует набор на время выбора режима, папки, раскладки, поведения зависимостей, манифеста и политики коллизий. Отмена не меняет ни файлы, ни сохранённые значения. -### Раскладка папок +#### Режимы и раскладки -| Вариант | Что делает | +Доступны два режима: + +- **Copy `.uasset`** — побайтово копирует пакеты, не загружая ассеты. Для выбранной карты также добавляются найденные OFPA-пакеты `.uasset`, иначе копия карты оказалась бы пустой. +- **Convert (FBX/PNG/WAV)** — загружает каждый ассет и запускает `UAssetExportTask` с настроенным расширением. По умолчанию после каждых 64 загруженных ассетов выполняется сборка мусора. + +Четыре взаимоисключающие раскладки: + +| Раскладка | Поведение | |---|---| -| **Flat** | Всё в одну папку. Ради этого и существует политика имён | -| **Mirror the content tree** | Воспроизводит дерево `/Game` | -| **One folder per asset** | Каждому отмеченному ассету — своя папка, зависимости рядом | -| **Migrate into another Unreal project** | Передаёт всё движковому `MigratePackages`. **Единственный вариант, после которого ссылки работают** | +| `Flat` | Все выходные файлы лежат в одной папке. Совпадения имён разрешает выбранная политика. | +| `MirrorTree` | Под корнем выгрузки воспроизводится пакетное дерево, например `/Game/Art/SM_Rock` превращается в `Game/Art/SM_Rock.uasset`. | +| `FolderPerAsset` | Для каждого вручную отмеченного ассета создаётся своя папка, рядом кладётся его замыкание зависимостей. Общая зависимость копируется в каждую папку, где нужна, поэтому размещений файлов бывает больше, чем уникальных ассетов. | +| `Migrate` | Только отмеченные пакеты передаются штатному `IAssetTools::MigratePackages`; Unreal сам строит замыкание и восстанавливает исходные пути в другом проекте. Это единственный управляемый движком вариант, рассчитанный на автоматическое сохранение рабочих Unreal-ссылок. | -### ⚠️ Копия `.uasset` не переносит зависимости сама по себе +При `FolderPerAsset` галочка **Sort dependencies into type subfolders** раскладывает зависимости по папкам пресетов вроде `Texture`, `Material`, `StaticMesh`; сам исходный ассет остаётся в корне своей папки. Для неприменимых раскладок контрол остаётся видимым, но выключенным. -Ссылки внутри `.uasset` — это **полные имена пакетов** (`/Game/Art/T_Rock_D`), а не относительные пути. Файл резолвится, только если лежит ровно по этому пути от `Content/` в целевом проекте. +Политика совпадения имён — **Keep both** с индексом либо **Overwrite**. В Migrate переименование невозможно: новый пакетный путь разрушил бы ссылки, которые перенос должен сохранить. Поэтому **Keep both** отображается на штатную политику Unreal **Skip**. -Отсюда: **`Flat` и `One folder per asset` дают файлы для людей, а не для движка.** Скопированные в проект, они откроются с битыми ссылками. Диалог говорит это прямо в подсказке под настройками. +#### Копия `.uasset` не переносит зависимости сама по себе -Для переноса в другой UE-проект есть `Migrate`. Указывать надо папку `Content/` целевого проекта. +Пакет хранит ссылки как полные имена вроде `/Game/Art/T_Rock_D`, а не как относительные пути к соседним файлам. Одиночный `SM_Rock.uasset` не содержит внутри своих материалов и текстур. Раскладки Flat и FolderPerAsset ещё и меняют положение пакета, поэтому их сырой результат предназначен для ревью/передачи либо должен восстанавливаться с информацией о путях. -⚠️ **Папка назначения обязана удовлетворять двум условиям движка**, иначе перенос молча не состоится: +Для прямого переноса Unreal → Unreal с управляемыми движком путями используйте **Migrate**. Папка назначения обязана: -1. путь оканчивается на `/Content/`; -2. на уровень выше лежит `.uproject` **или ровно один** `.uplugin` — из этого движок выводит точку монтирования. +1. оканчиваться директорией `Content`; +2. иметь на уровень выше `.uproject` либо ровно один `.uplugin`, чтобы Unreal мог вывести точку монтирования. -Проверка повторена у нас (`AssetUsagePaths::ValidateMigrateDestination`) и показывается прямо в окне: кнопка **Export** гаснет, причина стоит внизу. Так вышло не от аккуратности — первая версия отправляла папку `Saved/AssetUsageAudit`, движок отвечал `does not appear to be a game Content folder` **только в Output Log**, и в панели не происходило ничего. При переключении на `Migrate` подставленный путь сбрасывается: он заведомо непригоден. +`AssetUsagePaths::ValidateMigrateDestination` проверяет оба условия до включения кнопки выгрузки. При переключении на Migrate заранее подставленный непригодный путь очищается. `MigratePackages` возвращает `void` и сам сообщает результат через уведомления Unreal и Output Log, поэтому панель не выдумывает число скопированных файлов. -Про Migrate стоит знать: +В Migrate передаются только отмеченные пакеты: если отдать ещё и уже раскрытое плагином замыкание, Unreal будет повторно обходить лишние исходные точки. Отключение зависимостей выставляет `bIgnoreDependencies`; при этом Unreal пропускает и OFPA-пакеты уровня, поэтому панель показывает дополнительное подтверждение — перенесённый уровень может приехать пустым. -- Ему передаются **только отмеченные** пакеты — замыкание он строит сам. Скармливать ему ещё и наше означало бы тот же результат медленнее, с чужими ассетами в его отчёте. -- `MigratePackages` возвращает `void` и **отчитывается сам**. Поэтому в статусе панели не будет числа скопированных файлов: подделывать его нельзя. -- Политика имён к нему неприменима — Migrate обязан положить пакет по исходному пути, иначе теряется весь смысл. `Keep both` вырождается в `Skip`. -- ⚠️ Снятая галочка зависимостей означает `bIgnoreDependencies`, а он, по комментарию движка, **не переносит OFPA-акторов уровня**. Уровень приедет пустым. Панель спрашивает подтверждение отдельно. +#### Include referenced assets -### Манифест зависимостей +**Include referenced assets** включено по умолчанию. Для Copy/Convert плагин строит транзитивное замыкание каждого исходного ассета тем же пакетным запросом без требований, что и основной аудит. До записи окно показывает число отмеченных исходников и реальное число размещений файлов. -Для раскладок, ломающих ссылки, рядом с файлами пишется `AssetUsageAudit.manifest.json` — галочка **Write dependency manifest**, включена по умолчанию. +Границы обхода заданы явно: -Содержит: исходное имя пакета каждого файла, **фактический** путь на диске, был ли ассет отмечен вручную или пришёл зависимостью, и список прямых зависимостей с пометкой, уехали ли они в ту же папку. +- `/Script` — код и никогда не копируется; +- `/Engine` и `/Temp` по умолчанию исключены; +- проектные исключения путей ограничивают и раскрытие зависимостей; +- вручную отмеченный исходник проходит мимо этих фильтров, потому что явный выбор обязан дать сам файл; +- чужая карта, достигнутая из обычного ассета, не раскрывается; подуровень или Level Instance, структурно достигнутый из уровня либо внешнего пакета, обходится. -⚠️ **Фактический путь, а не задуманный.** Экспортёр возвращает карту записанного (`bRecordWrittenFiles`), и манифест строится из неё. Манифест из задуманных путей врал бы ровно в случае сработавшей политики имён — то есть когда он нужнее всего. +Это не `MigratePackages`: замыкание ограничено исключениями аудита, а размещением управляет плагин. -Пустой список записанных файлов — **ошибка**, а не пустой манифест: почти всегда это забытый флаг, а пустой манифест рядом с полной папкой будет принят за правду. По той же причине манифест не пишется под `Migrate`. +#### Манифест зависимостей -⚠️ **`One folder per asset` пишет больше файлов, чем ассетов.** Текстура на сорока мешах копируется в сорок папок — в этом и смысл: папку можно отдать целиком. Диалог подтверждения называет число файлов, а не число ассетов; это разные числа, и путать их дорого. +Для всех раскладок кроме Migrate по умолчанию включено **Write dependency manifest**: в корне появляется `AssetUsageAudit.manifest.json`. В нём записаны версия схемы, инструмент, проект, движок, раскладка, политика коллизий и для каждого реально записанного файла: -Галочка **Sort dependencies into type subfolders** раскладывает зависимости внутри папки ассета по типам — `Texture/`, `Material/`, `StaticMesh/`. Имена берутся из пресетов фильтра типов, а не выдуманы отдельно: в фильтре и на диске должны быть те же слова. Сам ассет остаётся в корне своей папки — он её предмет. Галочка активна только при `One folder per asset`; при других раскладках она **выключена, но видима** — исчезающий контрол читается как поломка. +- исходное пакетное имя; +- фактический путь относительно корня после разрешения коллизий; +- подсказки об ассете и классе, если они есть в реестре; +- был ли пакет отмечен вручную (`seed`) либо приехал зависимостью; +- его прямые пакетные зависимости и относительный файл для тех, что присутствуют в этой выгрузке. -### ⚠️ Include dependencies — включено по умолчанию +Манифест строится по записанным экспортёром фактическим путям, а не по запланированным именам. Именно при коллизии и индексировании разница критична. Пустой список записанных файлов считается ошибкой: правдоподобный пустой манифест рядом с полной папкой опаснее отсутствующего. Для Migrate генерация манифеста запрещена, потому что движок уже сохраняет пакетные пути. -Пакет меша **не содержит** материалов и текстур, только ссылки на них. Без этой галочки отмеченный `SM_Rock` приезжает один и открывается розовым; Niagara-система — без спрайтов и модулей. +#### Побайтовая копия и конвертация -Поэтому по умолчанию выгружается замыкание: отмеченное **плюс всё, на что оно ссылается**, транзитивно. Перед записью показывается диалог с реальным числом — «отмечено 40, будет записано 380». Это та цифра, которая останавливает человека, случайно выгружающего пол-проекта. +При перезаписи копирование снимает read-only с существующего назначения, а после записи — и с получившегося файла. Так read-only источника не превращает выгрузку в неизменяемую и не ломает следующий Overwrite. -Границы обхода: +Конвертация использует карту «класс → расширение» и поднимается вверх по иерархии. Запись для `Texture` поэтому может покрывать подклассы; результат появляется лишь при наличии подходящего `UExporter` в Unreal. Для Blueprint встроенной записи намеренно нет. У материалов, Blueprints и Niagara нет формата по умолчанию в этой карте. -| | | +Пропуски считаются раздельно: **нет настроенного формата**, **ассет не загрузился**, **для формата нет экспортёра**. Конвертация загружает ассеты и может быть ощутимо медленнее побайтового копирования; репозиторий не обещает универсальную производительность на больших пачках. + +### Импорт из внешней папки + +Откройте `Window → Tools → Import Assets from Folder...`. Этот сценарий рекурсивно сканирует `.uasset` и `.umap`, не загружая их, показывает кандидатов и позволяет выбрать, что вернуть в текущий проект. + +В таблице видны исходный файл, целевой пакет, источник целевого пути, состояние зависимостей и наличие цели в проекте. Путь восстанавливается из четырёх источников по убыванию доверия: + +| Источник | Значение | |---|---| -| Запрос | тот же `NoRequirements` — soft-ссылки ловятся наравне с hard | -| `/Engine`, `/Temp` | **не копируются** — в целевом проекте они уже есть, перезаписать хуже, чем пропустить | -| `/Script` | не копируются, это код | -| Исключения путей | те же, что у аудита: выгрузка не тянет то, что отчёт игнорирует | -| Отмеченное вручную | проходит **мимо** фильтров — если человек отметил строку, он получит файл, даже из исключённой папки | +| `Manifest` | Точный исходный пакет и фактическое имя выгруженного файла из `AssetUsageAudit.manifest.json`. | +| `Package header` | `FPackageFileSummary::PackageName`, если пакет сериализовал допустимое имя. Сам Unreal допускает отсутствие поля или `None`, поэтому откат необходим. | +| `Folder structure` | Вывод из положения файла под корнем сканирования. Точен для зеркальной раскладки плагина и остаётся догадкой для произвольной папки. | +| `Unresolved` | Путь восстановить не удалось. Нужна явно заданная запасная папка `/Game/...`, а исходные ссылки могут остаться сломанными. | -Это **не** `MigratePackages`: тот ходит по тому же замыканию, но сам решает, куда класть файлы, спрашивает и не умеет останавливаться на границе папки. +При наличии манифеста сканер также показывает зависимости, которых нет ни в импортируемой папке, ни в реестре текущего проекта. -### Копия `.uasset` +Правила импорта строгие, потому что у перезаписи нет undo и резервной копии: -Побайтовое копирование, ничего не загружается. Уровень тянет за собой свои OFPA-пакеты — без них выгруженная карта откроется пустой. +- существующая цель пропускается, пока явно не включено **Overwrite existing assets**; +- если выбранные строки будут перезаписаны, отдельное окно сообщает число и последствия; +- уже загруженный в редактор пакет не перезаписывается даже при включённом Overwrite: устаревшие объекты в памяти могли бы позже сохраниться поверх импортированного файла; +- файл без восстановленного пути пропускается, пока не указана допустимая запасная пакетная папка; +- неверные целевые пути и ошибки копирования выводятся отдельно. -⚠️ **С источника снимается атрибут read-only.** В проектах под VCS, которая держит невытянутые файлы read-only (Perforce и подобные), Windows `CopyFile` переносит атрибут на копию. Без этого артист получал бы нередактируемую папку, а повторный прогон с политикой `Overwrite` падал бы на собственном предыдущем выводе. Флаг снимается с обеих сторон: перед перезаписью и после копирования. +После успешной копии `ScanFilesSynchronous(..., bForceRescan=true)` делает файлы видимыми в Content Browser. Затем окно сканирует папку заново, и только что импортированные цели уже отмечены как существующие — второй клик не выглядит приглашением создать дубль. -### Конвертация в обменные форматы +### Удаление отмеченных ассетов -Через `UAssetExportTask` — меши в FBX, текстуры в PNG, звук в WAV. Загружает каждый ассет, поэтому счёт идёт на минуты, а не на секунды; GC каждые 64 ассета, иначе память кончится раньше экспорта. +**Delete ticked assets...** работает ровно по строкам, отмеченным в панели. Набор никогда не расширяется зависимостями: при выгрузке это полезно, а при удалении общая текстура могла бы снести контент, который никто не выбирал. -Соответствие класса и расширения — в настройках (`ExchangeFormatByClass`), поиск идёт вверх по иерархии: запись `Texture` покрывает `Texture2D`. Пустая карта означает встроенные умолчания. +Удаление проходит через два окна: -**Blueprint в умолчаниях отсутствует намеренно.** Обменного формата у него нет; запись породила бы `.t3d`-дамп с подписью «экспортировано». Вместо этого класс попадает в счётчик `нет настроенного формата`. +1. Собственное окно плагина показывает каждый запрошенный пакет: имя, тип, путь, число внешних референсеров и статус. Допустимые строки уже отмечены. Референсеры, которые сами входят в набор удаления, не считаются внешними; в тултип попадает до восьми имён, а общий счётчик остаётся точным. Референсеры-карты и OFPA отдельно считаются ссылками с уровня, read-only файлы помечаются заранее. Кнопка Delete выключена, пока не отмечено **I understand these files will be removed from the project**. +2. Подтверждённый набор заново разрешается в свежие `FAssetData` и передаётся в `ObjectTools::DeleteAssets(..., bShowConfirmation=true)`. После этого штатное окно Unreal **Delete Assets** управляет окончательным списком ссылок, **Force Delete** и **Replace References**. -Причины пропуска разделены на три счётчика — `нет формата` / `нет экспортёра` / `не загрузился`. «17 пропущено» не говорит ничего; «17 без настроенного формата» ведёт прямо в нужную настройку. +До штатного окна плагин запрещает: -Перед стартом показывается диалог с числом реально конвертируемых: узнать об ошибке в выборе режима лучше до нескольких минут заблокированного редактора, а не после. - ---- - -## Импорт из внешней папки - -`Window → Tools → Import Assets from Folder…` — обратное направление: прочитать папку выгруженных `.uasset` и вернуть выбранное в проект. - -Список с галочками, колонки: файл · во что превратится · **откуда взят путь** · статус. - -### Откуда берётся целевой путь - -Это главная сложность импорта, а не копирование. Ссылка внутри `.uasset` называет цель **полным путём пакета**, поэтому файл заработает только если положить его туда, откуда он пришёл. Ошибка здесь не падает громко — она даёт ассет с отсутствующими ссылками, который обнаружат сильно позже. - -Четыре источника по убыванию доверия, и колонка показывает, какой сработал: - -| Источник | Надёжность | +| Отказ | Причина | |---|---| -| **Manifest** | Мы сами его написали из фактически записанного. Точно | -| **Package header** | `FPackageFileSummary::PackageName` — имя, с которым файл сохраняли. **Замерено на этом проекте: работает** | -| **Folder structure** | Позиция файла под корнем импорта. Верно для зеркальной выгрузки, догадка для прочих | -| **Unresolved** | Не восстановить. Импортируется только в явно названную папку, ссылки не сработают | +| Уровень (`.umap`) | Это единица, относительно которой аудит измеряет использование; удаление уровня обесценивает остальные строки результата. Unreal также особо обрабатывает открытую карту. Осознанно удаляйте карты из Content Browser. | +| OFPA-пакет из `__ExternalActors__` или `__ExternalObjects__` | Это размещённый объект/актор карты. Удалять его следует в редакторе уровня, где операция участвует в его рабочем процессе и undo. | +| `/Engine`, `/Script`, `/Temp` | Для этого инструмента это не удаляемый контент проекта. | +| Отсутствующий пакет | В реестре уже нет ассета с таким пакетным именем. | -⚠️ Про `Package header` есть оговорка **в самом движке** (`AssetHeaderPatcher.cpp:1214`): поле сериализуется не всегда, и движок сам предусматривает откат. Поэтому цепочка, а не одна проверка. +Внешние референсеры и read-only — предупреждения, а не молчаливое исключение: последнее решение принимает штатное окно Unreal. Восстановление обеспечивает система контроля версий; плагин не создаёт резервную копию и не даёт собственного undo. -### Что импорт делать откажется +После удаления исчезнувшие строки убираются, но другие строки пока несут счётчики и вердикты из старого графа. Панель помечает результат устаревшим и просит повторить аудит. `Unreferenced` всё равно не доказывает отсутствия использования через динамическую строку, строку DataTable или FMOD; окно удаления повторяет это предупреждение. -Импорт — единственная операция инструмента, способная **уничтожить работу**: запись поверх `/Game/Art/SM_Rock` заменяет то, что там было, без отмены и без копии. +### Архитектура -- Существующий ассет **пропускается**, пока явно не включена перезапись. Перед перезаписью — отдельное подтверждение. -- Пакет, **открытый в редакторе**, не перезаписывается никогда, даже с включённой галочкой: подмена файла под загруженным `UPackage` оставляет сессию с устаревшими объектами, которые потом сохранятся поверх импорта. -- Файлы без восстановимого пути пропускаются, если не названа папка-приёмник. +| Модуль | Тип | Ответственность | +|---|---|---| +| `AssetUsageAuditCore` | `UncookedOnly`, `Default` | Построение графа, транзитивная достижимость от уровней, OFPA, вердикты, косвенный скан, отчёты, замыкание зависимостей, раскладка/копирование/конвертация/манифест, скан и копирование импорта, предварительный анализ удаления. Никакого Slate UI и редакторного asset pipeline. | +| `AssetUsageAuditEditor` | `Editor`, `PostEngineInit` | Докируемая Slate-панель, меню и тулбар, настройки, консольные команды, Blueprint/Python-библиотека, окна выгрузки/импорта/удаления, вызов Migrate и передача в штатное удаление. | +| `AssetUsageAuditTests` | `UncookedOnly`, `Default` | Automation Specs для Core и проверки в editor context/на живом контенте, включая Migrate. | -После копирования выполняется `ScanFilesSynchronous` — без него файлы лежат на диске и невидимы в Content Browser, что читается как «импорт ничего не сделал». +Все три записи дескриптора разрешены только для Win64. `AssetUsageAuditCore` публично зависит от `Core`, `CoreUObject`, `AssetRegistry`, приватно — от рантайм-модулей `Engine`, `Json`, `Projects`. Его инвариант — не зависеть от `UnrealEd`, `AssetTools`, `ToolMenus`, `Slate`, `SlateCore` и editor subsystems. `Engine` допустим: `ULevel::GetExternalActorsPaths`, `GetExternalObjectsPaths` и скан ассетов уровня — рантайм-API, доступные коду, рассчитанному на commandlet. ---- +#### Инварианты корректности -## Удаление отмеченных ассетов +1. **Обход зависимостей использует пакетную категорию и `FDependencyQuery{}` (`NoRequirements`).** Hard-only теряет soft-ссылки и рёбра OFPA. `Soft` — отрицательное требование Hard, поэтому одновременное требование Hard и Soft не означает «любой из двух». Hard/soft остаётся данными отчёта и никогда не становится фильтром обхода. +2. **Граница карты пересекается только из уже принятого уровня либо его внешнего пакета.** Так проходят стриминговые подуровни и Level Instances, но обычная ссылка из data asset/Blueprint не затягивает чужую карту со всем содержимым. Внутренний параметр `bTraverseIntoOtherLevels` оставляет расширенный режим вызывающему коду, которому он действительно нужен. +3. **Корни OFPA берутся из `ULevel::GetExternalActorsPaths` и `GetExternalObjectsPaths`, а не собираются строками.** Content Bundles, External Data Layers и делегаты плагинов могут добавлять структуру пути. Обход идёт вперёд от карты и не придумывает обратное ребро от внешнего пакета к уровню. +4. **Класс Blueprint-ассета и сгенерированный геймплейный класс разделены.** Класс самого ассета — `/Script/Engine.Blueprint`; геймплейная фильтрация использует тег реестра `GeneratedClass` и раскрытие подклассов. +5. **До обхода уровня по умолчанию выполняется `ScanLevelAssets`.** Иначе не открывавшийся в сессии OFPA-уровень может выглядеть пустым: его внешние пакеты ещё не попали в реестр. -Кнопка **Delete ticked assets…** внизу панели, рядом с выгрузкой. Работает по тому же набору галочек. +Состояние галочек хранится в множествах пакетных имён, а не в selection `SListView`, поэтому фильтрация не стирает выбор пользователя. Смена области не запускает аудит сама. Отчёты и все публичные точки входа зовут один Core-аудитор. Конвертация остаётся в Core, потому что `UExporter` и `UAssetExportTask` относятся к Engine API; конкретные экспортёры разрешаются в рантайме, а отсутствие даёт явную причину пропуска. Migrate находится в Editor, потому что `AssetTools` — editor-only. -Удаление — единственная операция инструмента, которая уничтожает работу безвозвратно, поэтому проходит **через два окна**. +### Тесты -### Окно 1 — наше +В `Source/AssetUsageAuditTests/Private` находятся 16 Automation Spec-файлов `*.spec.cpp`. Подсчёт определений `It(...)` в них даёт 169 кейсов. Префикс тестов — `AssetUsageAudit`. -Показывает весь отмеченный набор построчно: имя, тип, путь, **сколько ассетов снаружи набора ещё ссылается** на строку (в тултипе — их имена), и статус. +Сначала соберите Editor-таргет, затем запустите из командной строки Windows: -- Ссылающиеся ассеты, которые сами удаляются вместе с целью, **в счётчик не попадают**. Иначе удаление блюпринта вместе с его единственным мешем выглядело бы опасным, хотя оно чистое. -- Референсеры запрашиваются тем же `NoRequirements`-запросом, что и аудит: hard-only потерял бы каждую soft-ссылку, а soft-ссылка ломается точно так же — просто в рантайме, а не при загрузке. -- Файл, помеченный **read-only на диске**, отмечается заранее. Под VCS, которая держит невытянутые файлы нередактируемыми, без этой пометки удаление падало бы по одному файлу в середине пачки. -- Кнопка удаления неактивна, пока не поставлена галочка «I understand these files will be removed from the project». - -### Окно 2 — родное от UE - -После подтверждения набор уходит в `ObjectTools::DeleteAssets(..., bShowConfirmation=true)`, и открывается **штатное окно Delete Assets** движка — со списком ссылок, `Force Delete` и `Replace References`. - -Оно не имитировано, а вызвано: своя реализация замены ссылок означала бы свою реализацию `FAssetDeleteModel`, ошибки которой всплыли бы на чужом проекте. - -### Что удалять запрещено - -Отказы показываются в списке с причиной, строку нельзя отметить — молча выкинуть её из набора было бы хуже, чем отказать вслух. - -| Отказ | Почему | -|---|---| -| **Уровень** (`.umap`) | Уровень — это единица, относительно которой инструмент меряет использование. Удалив его, обесцениваешь каждую другую строку результата. Плюс движок сам отказывается удалять открытую карту, и итог зависел бы от того, какая карта сейчас загружена | -| **OFPA-пакет** (`__ExternalActors__`, `__ExternalObjects__`) | Это не ассет, а размещённый на карте актор. Удалять его мимо level-редактора — значит править карту за его спиной, без его undo | -| **`/Engine`, `/Script`, `/Temp`** | Не контент этого проекта | -| **Уже отсутствует** | В реестре нет ассета под этим именем — удалять нечего | - -### ⚠️ Набор для удаления не расширяется зависимостями - -В отличие от выгрузки. Выгрузка тянет зависимости, чтобы меш приехал с материалами; удаление, расширенное так же, снесло бы контент, который никто не отмечал, — одна общая текстура утащила бы половину проекта. - -Удаляется ровно отмеченное и никогда больше. - -### ⚠️ После удаления результат помечается устаревшим - -Удалённые строки убираются из таблицы, но всё, что на них ссылалось, сохраняет прежние счётчики ссылок, а ассет, бывший `UsedByAssetsOnly` через удалённый блюпринт, теперь имеет другой вердикт. Панель говорит об этом прямо и просит перезапустить аудит, а не выдаёт старые числа за свежий замер. - -И главное — то же, что и везде в этом инструменте: **`Unreferenced` не означает «не используется»**. Реестр не видит путей, собранных строками, содержимого DataTable и событий FMOD. Окно удаления повторяет это предупреждение прямо в тексте. - ---- - -## Архитектура - -``` -AssetUsageAuditCore UncookedOnly — весь анализ, ноль UI -AssetUsageAuditEditor Editor — Slate-панель, настройки, команды -AssetUsageAuditTests UncookedOnly — 16 spec-сьютов, 169 кейсов +```bat +"D:\Epic\UE_5.6\Engine\Binaries\Win64\UnrealEditor-Cmd.exe" ^ + "D:\Projects\MyGame\MyGame.uproject" ^ + -ExecCmds="Automation RunTests AssetUsageAudit" ^ + -TestExit="Automation Test Queue Empty" ^ + -unattended -nopause -nosplash -stdout ^ + -abslog="D:\Projects\MyGame\Saved\Logs\AssetUsageAuditTests.log" ``` -**Инвариант Core:** не линковать `UnrealEd`, `AssetTools`, `ToolMenus`, `Slate`, `SlateCore`, `EditorSubsystem`. `AssetTools` editor-only транзитивно через `UnrealEd`. +На машине без рабочего стола/GPU-сессии добавьте `-nullrhi`, чтобы Unreal не инициализировал графическое устройство: -`Engine` в Core **разрешён** — Runtime-модуль, доступен в коммандлете. Нужен для `ULevel::GetExternalActorsPaths`. - ---- - -## ⚠️ Инварианты, которые нельзя нарушать - -Проверено чтением исходников UE 5.6. Без этого инструмент молча даёт неверный ответ. - -### 1. Запрос зависимостей — всегда `NoRequirements` - -```cpp -AssetUsageAudit::MakeTraversalCategory() // EDependencyCategory::Package -AssetUsageAudit::MakeTraversalQuery() // FDependencyQuery{} — пустой +```bat +"D:\Epic\UE_5.6\Engine\Binaries\Win64\UnrealEditor-Cmd.exe" ^ + "D:\Projects\MyGame\MyGame.uproject" ^ + -ExecCmds="Automation RunTests AssetUsageAudit" ^ + -TestExit="Automation Test Queue Empty" ^ + -unattended -nopause -nosplash -stdout -nullrhi ^ + -abslog="D:\Projects\MyGame\Saved\Logs\AssetUsageAuditTests.log" ``` -Два факта движка: +Часть сьютов намеренно исследует настоящий контент хост-проекта и пропускает отдельные проверки с предупреждением, если подходящего объекта нет. В частности, `AssetUsageAudit.ExporterLive`, `AssetUsageAudit.ExchangeExport`, `AssetUsageAudit.GraphFidelity`, `AssetUsageAudit.MigrateLive` ищут пригодные ассеты/уровни через Asset Registry, а не полагаются на фиксированные имена пакетов. Предупреждения нужно читать: они отличают реально отработавшую live-проверку от пропущенной. -**`ExternalObjectAndActorDependencyGatherer.cpp:22`** выдаёт рёбра карта→внешний актор с маской `Game | Build` — **без `Hard`**. А `AssetRegistryInterface.h:95`: отсутствие `Hard` **и есть** soft-зависимость. Запрос с `EDependencyQuery::Hard` теряет все **16 117** OFPA-пакетов проекта. +`GraphFidelity` сравнивает стабильную выборку по пакетным именам с сырым ответом Asset Registry: пропущенные и лишние рёбра, hard-классификацию по цели, реальные переходы Route, soft-инвариант OFPA и приписывание подуровня. Выборка по именам убирает нестабильность порядка перечисления реестра; hard сравнивается по цели, потому что Unreal может вернуть одну цель одновременно hard- и soft-ребром. -**`Soft` определён как `NotHard`.** Значит `Hard | Soft` = «требуется Hard И требуется не-Hard» = пустое множество. +В прежнем хост-проекте был зафиксирован не связанный с плагином fatal `TNotNull` при `-nullrhi` даже с выключенными плагинами, а `-NoShaderCompile` приводил к отсутствующей shader map материала по умолчанию. Для того проекта эти флаги не давали успешного headless-запуска. В самом репозитории плагина нет `.uproject`, поэтому реальный прогон всегда требует хост-проект UE 5.6 с установленным плагином. -Hard/soft — это **колонка в отчёте**, а не фильтр запроса. Отдельной константы `Hard` в коде нет — ошибиться негде. +Тесты, которые намеренно вызывают `UE_LOG(Error)`, объявляют сообщение через `AddExpectedError`; иначе Automation Framework справедливо считает ошибку в логе падением теста. -Есть канарейка: если достижимых ассетов меньше четверти от числа OFPA-пакетов, в лог падает предупреждение. Это сигнатура регресса к `Hard`-запросу. +### Ограничения -### 2. Границу карты пересекать только от уровня или его внешнего пакета +- Это редакторный плагин с исходниками, а не рантайм-система. В нём нет игрового UI и аудита в собранной игре. +- В дескрипторе заявлен только Win64. Другие платформы не собраны и не поддерживаются метаданными репозитория. +- В репозитории нет хостового `.uproject`, готовых DLL, отдельного commandlet, бейджей, скриншотов и внешних страниц документации. +- Свидетельства Asset Registry не покрывают динамически собранные пути, загрузку уровня по имени, произвольные строки DataTable/CurveTable и строковые события FMOD. `Unknown` и даже `Unreferenced` требуют проверки человеком. +- Скан Config/Source находит буквальные `/Game`-пути в поддерживаемых текстовых файлах; это не анализ потока данных C++ или Blueprint. +- Flat и FolderPerAsset не переписывают внутренние пакетные ссылки. Для восстановления используйте манифест и импорт, для прямого переноса проекта — штатный Unreal Migrate. +- Конвертация зависит от установленных и зарегистрированных экспортёров Unreal. Класс без настроенного формата пропускается; расширение без экспортёра считается отдельно. Большие партии загружают ассеты, а тесты репозитория проверяют отдельные выгрузки, но не публикуют универсальный замер большого объёма. +- У удаления и перезаписи нет собственного undo или резервной копии плагина. Защита — явные подтверждения и система контроля версий. +- Slate-взаимодействие не покрывается Automation Specs; после изменений UI нужен smoke-test в редакторе. +- Live-сьюты могут предупредить и пропустить проверку, если в хост-проекте нет подходящего настоящего контента. Зелёный прогон нужно читать вместе с предупреждениями. +- Архитектура Core рассчитана на использование из commandlet, но отдельного audit-commandlet в репозитории нет. -Первая версия проваливалась в любой встреченный World. Замер на `WP_Example`: **18 136** строк, из них **9 994** приходили через чужую карту: +### Лицензия -``` -WP_Example → BP_GameMode → PDA_MenuConfig → L_Other → … -``` +Файла `LICENSE` в репозитории нет. Исходный код опубликован для демонстрации портфолио и ревью; условия повторного использования, распространения или включения в другой проект нужно запросить у автора. Никакая сторонняя лицензия здесь не подразумевается. -Две трети ответа были содержимым другого уровня. После исправления — **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 -"/Engine/Build/BatchFiles/Build.bat" \ - Editor Win64 Development \ - -project="/.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="" -``` - -**169 кейсов в 16 spec-сьютах** - -Без десктопа (CI, ssh-сессия, служба) добавляйте `-nullrhi`: редактор стартует, но -инициализация D3D12 в сессии без рабочего стола встаёт намертво и лог замирает на -загрузке Asset Registry — до тестов дело не доходит. С `-nullrhi` прогон проходит -целиком, на результат это не влияет: наборы работают с Asset Registry и экспортом, -а не с рендером., префикс `AssetUsageAudit.*`. - -Три набора работают на **настоящем контенте проекта**, а не на выдуманных именах: `ExporterLive` (копирование, коллизии, раскрытие OFPA), `ExchangeExport` (конвертация) и `GraphFidelity` (сверка графа с реестром). Субъект они ищут через Asset Registry и берут **самый маленький** OFPA-уровень — на `WP_Example` тест копировал бы гигабайты. Если контент не найден, тест пишет предупреждение и не падает, поэтому в логе стоит смотреть на предупреждения: их отсутствие означает, что тесты реально работали с контентом. - -### `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_Example` | ~1.6 с, 8 644 ассета | -| Все уровни | 33 261 `UsedOnLevel` | -| Отчёт | 45 167 строк, JSON 21.8 МБ + CSV 10.2 МБ | - -**Кэш не нужен** — вопрос закрыт замером. - ---- - -## Незакрытое - -- **UI после переделки в дерево руками не проверялся.** Компилируется, но автотесты не трогают Slate: раскрытие уровней, третье состояние галочки на заголовке и сортировка внутри группы проверены только чтением кода. -- **Коммандлет не написан.** Упирается в дефект headless выше. -- **Конвертация не прогонялась на большом объёме.** Тесты покрывают единицы ассетов; поведение GC и времени на тысячах — не замерено. -- **Карта форматов узкая.** В дефолте только классы, для которых движок реально поставляет `UExporter`. Материалы, блюпринты и Niagara не конвертируются никуда — это ограничение движка, а не недоделка, но пользователя нужно об этом предупредить явно. -- GameMode может давать заметную долю строк отчёта по главной карте — нужен ли фильтр «исключить достижимое только через GameMode», решать по месту. -- Ассеты сторонних аудио-плагинов попадают в `Unknown` — скрывать по умолчанию или помечать, решать по месту. - ---- - -## Контроль версий - -Плагин — **самостоятельный git-репозиторий**. Артефакты сборки (`Binaries/`, `Intermediate/`) исключены локальным `.gitignore`: они пересобираются UBT из исходников и в коммит попадать не должны. - -При подключении к проекту как сабмодуль: - -```bash -git submodule add Plugins/AssetUsageAudit -``` - - -## Связанное - -- `Plugins/AssetsCleaner` — ищет неиспользуемые ассеты, но **не умеет привязку к уровням** +- Репозиторий: https://git.kodlo.art/Kodlo/AssetUsageAudit +- Автор: Kodlo (MagentaDolphin)