feat: asset usage audit plugin

Editor tool for the LA and 3D departments: which assets each level uses,
which are used nowhere, and export of a chosen set.

Three modules. AssetUsageAuditCore holds the whole analysis and links no
UI and no editor-only asset pipeline, so it stays runnable from a
commandlet; AssetUsageAuditEditor holds the Slate panel and everything
that needs UnrealEd or AssetTools; AssetUsageAuditTests holds 152 specs.

Load-bearing decisions, each of which produces a wrong answer if undone:

- Dependency queries are always Package + NoRequirements, never Hard. The
  map-to-external-actor edges the OFPA gatherer emits carry Game|Build
  without Hard, so a Hard query drops all 16117 external actor packages
  in this project. There is deliberately no Hard constant in the code.
- Crossing into another map is allowed only from a level or its external
  actor package. Without that rule WP_Main reported 18136 assets, of
  which 9994 belonged to L_MainLevel, reached through the GameMode.
- The verdict has five states, never a bool. The registry cannot see
  FMOD events, DataTable rows or string-built paths; those are Unknown,
  and the tool never proposes a deletion.
- Copying .uasset files does not preserve references - they are stored as
  full package paths. Only the Migrate layout produces something Unreal
  can open; the others write a manifest so the graph can be rebuilt.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-03 17:07:59 +07:00
commit 72bf94d142
60 changed files with 13673 additions and 0 deletions
@@ -0,0 +1,128 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
class IAssetRegistry;
/**
* Everything a set of packages needs in order to be usable somewhere else.
*
* Exists because "export the ticked meshes" without this produces meshes that open pink: the
* package file of a StaticMesh holds no materials and no textures, only references to them. The
* same applies to a Niagara system and its sprites, and to a Blueprint and everything it spawns.
*
* Deliberately a separate step rather than something FAssetUsageExporter does internally. The
* exporter stays a dumb file copier that needs no Asset Registry and is testable without one, and
* the caller keeps the chance to show "40 ticked, 380 will be written" before anything is copied -
* which matters, because that ratio surprises people.
*
* This is NOT IAssetTools::MigratePackages. Migrate walks the same closure but also decides where
* files go, prompts, and cannot be told to stop at a folder boundary. Here the traversal is bounded
* by the same exclusion list the audit uses, and engine content is left out by default because the
* destination project already has it.
*/
namespace AssetDependencyClosure
{
struct FOptions
{
/**
* Package prefixes not to descend into, in either /Game/... or Content/... form.
* Normally the audit's own exclusion list, so an export cannot pull in content the report
* deliberately ignores.
*/
TArray<FString> ExcludePackagePaths;
/**
* Include /Engine and /Temp packages in the result.
*
* Off by default. A mesh using DefaultMaterial genuinely depends on /Engine content, but
* copying it into the export folder is almost never what someone wants - the destination
* project ships the same file, and overwriting it there is worse than useless.
*/
bool bIncludeEnginePackages = false;
/**
* Stop after this many hops from a seed. Zero means no limit.
*
* A limit is a blunt instrument and changes the answer rather than just shortening it, so it
* is off by default. It exists for the "just the materials, not the whole graph" case.
*/
int32 MaxDepth = 0;
/** Called as (Visited, Queued). Return false to stop; the partial result is still returned. */
TFunction<bool(int32, int32)> OnProgress;
};
// Exported: Summarise is defined out of line, so the Editor module cannot link without this.
struct ASSETUSAGEAUDITCORE_API FStats
{
/** Seeds that were valid package names to begin with. */
int32 SeedCount = 0;
/** Size of the returned array, seeds included. */
int32 TotalCount = 0;
/** Reached but dropped, per reason. Kept apart so a surprising result can be explained. */
int32 SkippedScript = 0;
int32 SkippedEngine = 0;
int32 SkippedExcluded = 0;
/**
* Maps reached through an ordinary asset rather than through a level, and therefore not
* followed - nor included.
*
* The same rule FLevelUsageResolver applies, and for the same measured reason: on WP_Main
* the chain BP_FirstPersonGameMode -> PDA_MenuSystemConfig -> L_MainLevel drags in 9994
* packages belonging to a different map. Without this the export dialog would quote an
* honest number for a wrong set.
*
* Excluded rather than merely not expanded, unlike in the audit. The exporter expands any
* .umap it is handed into its One File Per Actor packages, so including the map file would
* pull the foreign level's contents back in through the exporter instead of the closure.
*/
int32 ForeignLevelsSkipped = 0;
/** Deepest hop count actually reached. Tells you whether MaxDepth did anything. */
int32 DeepestHop = 0;
bool bStoppedEarly = false;
FString Summarise() const;
};
/**
* Seeds plus everything they depend on, transitively.
*
* Seeds come first and in their original order, so a caller can still tell what was asked for.
* The traversal uses AssetUsageAudit::MakeTraversalQuery() - package category, no requirements -
* for the same reason the audit does: a Hard-only query silently drops every soft edge, which
* includes every One File Per Actor package and every TSoftObjectPtr a Blueprint resolves.
*/
/** One ticked asset and everything it pulls in, the seed first. */
struct FSeedClosure
{
FName Seed;
TArray<FName> Packages;
};
/**
* Closure per seed, rather than one closure over all of them.
*
* Needed by the folder-per-asset layout: a texture shared by forty meshes has to appear in all
* forty folders, and the flat Gather deliberately returns it once. Costs one traversal per
* seed, so it is the slower call by construction - use Gather when a single list will do.
*/
ASSETUSAGEAUDITCORE_API TArray<FSeedClosure> GatherPerSeed(
IAssetRegistry& Registry,
const TArray<FName>& Seeds,
const FOptions& Options,
FStats& OutStats);
ASSETUSAGEAUDITCORE_API TArray<FName> Gather(
IAssetRegistry& Registry,
const TArray<FName>& Seeds,
const FOptions& Options,
FStats& OutStats);
}
@@ -0,0 +1,103 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "AssetDependencyClosure.h"
#include "AssetUsageAuditTypes.h"
#include "AssetUsageExporter.h"
class IAssetRegistry;
/**
* Decides where each exported file goes.
*
* Deliberately separate from FAssetUsageExporter. The exporter's job is to copy a package to a
* path and nothing else - it has no idea what a preset or a dependency is, and keeping it that way
* is why its tests can run against invented package names. Everything that needs the Asset
* Registry, the class hierarchy or the dependency closure lives here instead, and hands the
* exporter a finished list of "these packages, into this subfolder".
*/
namespace AssetExportLayout
{
/**
* A set of packages destined for one subfolder of the export target.
*
* An alias, not a second struct: this namespace exists to produce exactly what the exporter
* consumes, and two identical types would mean a conversion loop whose only job is to prove
* they stayed identical.
*/
using FGroup = FAssetUsageExporter::FExportGroup;
struct FOptions
{
EExportLayout Layout = EExportLayout::Flat;
/**
* Sort each asset's dependencies into per-type subfolders: Texture/, Material/, and so on.
*
* Only meaningful with FolderPerAsset - there is no "each asset's dependencies" to sort in
* the other layouts. BuildGroups ignores it rather than inventing a meaning.
*
* The seed itself stays at the root of its own folder. It is the subject of that folder;
* filing it under StaticMesh/ next to its own dependencies would bury it.
*/
bool bGroupDependenciesByType = false;
};
struct ASSETUSAGEAUDITCORE_API FStats
{
int32 GroupCount = 0;
/** Distinct packages across every group. */
int32 DistinctPackages = 0;
/**
* Total placements, counting a shared dependency once per folder it lands in.
*
* This, not DistinctPackages, is the number of files that will be written. Under
* FolderPerAsset the two differ sharply and the user has to be told which one they are
* looking at before the copy starts.
*/
int32 FilePlacements = 0;
/** Folder names that collided and were given a numeric suffix. */
int32 RenamedFolders = 0;
FString Summarise() const;
};
/**
* Build the export groups.
*
* @param SeedClosures For FolderPerAsset, one entry per ticked asset from GatherPerSeed. For
* the other layouts only the union of Packages is used, so a single
* closure covering everything is enough.
*/
ASSETUSAGEAUDITCORE_API TArray<FGroup> BuildGroups(
IAssetRegistry& Registry,
const TArray<AssetDependencyClosure::FSeedClosure>& SeedClosures,
const FOptions& Options,
FStats& OutStats);
/**
* Map every class the type presets cover, and their subclasses, to a folder name.
*
* Folder names are the preset names verbatim - Texture, Material, StaticMesh - rather than a
* second set of invented labels. The Types filter in the panel already shows those words, so
* the folders a user gets match the words they filtered by.
*
* Built once per export: expanding the class hierarchy costs a registry call per preset.
*/
ASSETUSAGEAUDITCORE_API TMap<FTopLevelAssetPath, FString> BuildTypeFolderMap(IAssetRegistry& Registry);
/**
* Folder name for one package, using a map from BuildTypeFolderMap.
* Returns "Other" for anything no preset covers, never an empty string - an empty name would
* silently put the file in the parent folder and look like the grouping had failed.
*/
ASSETUSAGEAUDITCORE_API FString TypeFolderForPackage(
IAssetRegistry& Registry,
FName PackageName,
const TMap<FTopLevelAssetPath, FString>& FolderByClass);
}
@@ -0,0 +1,83 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "AssetUsageAuditTypes.h"
#include "AssetUsageExporter.h"
class IAssetRegistry;
/**
* The JSON written beside a reference-breaking export.
*
* Why it exists: references inside a .uasset are stored as full package names, so a copied file
* only resolves when it sits at exactly that package path in the destination. The Flat and
* FolderPerAsset layouts deliberately do not put it there - they arrange files for a person to read
* and hand around - so the reference graph is lost the moment the files leave the project.
*
* The manifest records that graph next to the files, so a later import can rebuild it rather than
* guess. It is written from what the exporter actually wrote, never from what it intended to write:
* the two differ exactly when the collision policy renamed something, which is the case a manifest
* has to get right.
*
* Migrate needs none of this - the engine preserves the paths itself - and asking for a manifest
* there is treated as a caller mistake rather than silently producing a misleading file.
*/
namespace AssetExportManifest
{
/** Bumped when the schema changes in a way a reader must notice. */
inline constexpr int32 SchemaVersion = 1;
/** Manifest filename, written into the export root. */
inline const TCHAR* FileName = TEXT("AssetUsageAudit.manifest.json");
struct FOptions
{
/** Export root. The manifest is written here and paths are relative to it. */
FString TargetDirectory;
/** Recorded so a reader knows why the paths look the way they do. */
EExportLayout Layout = EExportLayout::Flat;
EExportCollisionPolicy CollisionPolicy = EExportCollisionPolicy::Index;
/**
* Packages the user ticked, as opposed to those pulled in as dependencies.
*
* Kept apart because an importer needs to know which assets were the point and which came
* along to make them work - "restore what I exported" and "restore everything in this
* folder" are different requests.
*/
TSet<FName> SeedPackages;
};
struct ASSETUSAGEAUDITCORE_API FResult
{
bool bSuccess = false;
/** Absolute path of the manifest, empty on failure. */
FString FilePath;
int32 EntriesWritten = 0;
/** Dependency edges recorded across all entries. */
int32 EdgesRecorded = 0;
FString ErrorMessage;
};
/**
* Write the manifest.
*
* @param WrittenFiles From FAssetUsageExporter::FResult::WrittenFiles, which requires the
* exporter to have been run with bRecordWrittenFiles. An empty array is a
* failure rather than an empty manifest: it almost always means the flag
* was forgotten, and an empty manifest beside a full folder is worse than
* no manifest at all.
*/
ASSETUSAGEAUDITCORE_API FResult Write(
IAssetRegistry& Registry,
const TArray<FAssetUsageExporter::FWrittenFile>& WrittenFiles,
const FOptions& Options);
}
@@ -0,0 +1,57 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "AssetUsageAuditTypes.h"
/**
* Collision-handling for export filenames.
*
* Deliberately a free function over strings with an injectable existence predicate: the rule
* ("increment an existing index, do not append a second one") is fiddly on real asset names like
* SM_Rock_02_v3, and it must be unit-testable without touching a filesystem or an editor.
*/
namespace AssetExportNaming
{
/** Predicate answering "does this filename already exist in the target folder". */
using FExistsPredicate = TFunctionRef<bool(const FString& /*FileName*/)>;
/**
* Split a base name into stem and trailing numeric index.
*
* "Foo" -> {"Foo", INDEX_NONE, 0}
* "Foo_1" -> {"Foo", 1, 1}
* "Foo_007" -> {"Foo", 7, 3} <- padding width preserved
* "SM_Rock_02_v3"-> {"SM_Rock_02_v3", INDEX_NONE, 0} <- v3 is not a numeric suffix
* "SM_Rock_02" -> {"SM_Rock", 2, 2}
* "Foo_" -> {"Foo_", INDEX_NONE, 0} <- empty suffix is not an index
*
* @param BaseName Filename without extension.
* @param OutStem Portion before the trailing _N, or the whole name when there is none.
* @param OutIndex Parsed index, or INDEX_NONE.
* @param OutPadWidth Digit count of the parsed index, so Foo_007 -> Foo_008 not Foo_8.
*/
ASSETUSAGEAUDITCORE_API void SplitTrailingIndex(const FString& BaseName, FString& OutStem, int32& OutIndex, int32& OutPadWidth);
/**
* Compose a filename from stem, index and padding.
* (Foo, INDEX_NONE, 0) -> "Foo"
* (Foo, 1, 1) -> "Foo_1"
* (Foo, 8, 3) -> "Foo_008"
*/
ASSETUSAGEAUDITCORE_API FString ComposeIndexedName(const FString& Stem, int32 Index, int32 PadWidth);
/**
* Resolve a target filename under the given collision policy.
*
* Overwrite -> returns DesiredFileName unchanged.
* Index -> returns the first non-colliding name, incrementing any existing trailing index.
*
* @param DesiredFileName Filename with extension, e.g. "SM_Rock.uasset".
* @param Policy Overwrite or Index.
* @param Exists Predicate over filenames (with extension) in the target folder.
* @return Filename with extension that does not collide, or DesiredFileName under Overwrite.
*/
ASSETUSAGEAUDITCORE_API FString ResolveCollision(const FString& DesiredFileName, EExportCollisionPolicy Policy, FExistsPredicate Exists);
}
@@ -0,0 +1,85 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
class IAssetRegistry;
struct FAssetUsageNode;
/**
* Named groups of asset classes, so an artist picks "VFX" rather than typing a class path.
*/
enum class EAssetTypePreset : uint8
{
StaticMesh,
SkeletalMesh,
Material,
Texture,
VFX,
Sound,
Blueprint,
Level,
DataAsset,
Animation
};
ASSETUSAGEAUDITCORE_API const TCHAR* LexToString(EAssetTypePreset Preset);
/** Every preset, in the order the UI should list them. */
ASSETUSAGEAUDITCORE_API TArray<EAssetTypePreset> GetAllAssetTypePresets();
/** Class paths a preset stands for, before subclass expansion. */
ASSETUSAGEAUDITCORE_API TArray<FTopLevelAssetPath> GetPresetClassPaths(EAssetTypePreset Preset);
/**
* Matches assets by class, with the two things that make class filtering work in this project.
*
* 1. Subclass expansion via GetDerivedClassNames, so "Material" also matches MaterialInstanceConstant.
*
* 2. Blueprint awareness. A BP asset's own class is always /Script/Engine.Blueprint, so filtering on
* class alone finds no Blueprints at all - fatal in a project whose gameplay is entirely Blueprints.
* The gameplay class lives in the GeneratedClass registry tag, captured per node during the sweep,
* and a node matches when EITHER its class or its generated class is in the expanded set.
*/
class ASSETUSAGEAUDITCORE_API FAssetTypeFilter
{
public:
/** Empty filter matches everything. */
FAssetTypeFilter() = default;
void AddPreset(EAssetTypePreset Preset);
/** Raw class path, e.g. "/Script/Engine.StaticMesh" or a short name like "StaticMesh". */
void AddRawClass(const FString& ClassPathOrName);
/**
* Expand every added class to include its subclasses.
* Must be called after the Add* calls and before Matches.
*/
void Compile(IAssetRegistry& AssetRegistry);
bool IsEmpty() const
{
return RequestedClasses.IsEmpty();
}
bool Matches(const FAssetUsageNode& Node) const;
/** Human-readable description for the report header. */
FString Describe() const;
/** Raw class strings that could not be resolved to a real class. */
TConstArrayView<FString> GetUnresolvedClasses() const
{
return UnresolvedClasses;
}
private:
TArray<FTopLevelAssetPath> RequestedClasses;
TArray<FString> RequestedDescriptions;
TArray<FString> UnresolvedClasses;
TSet<FTopLevelAssetPath> ExpandedClasses;
bool bCompiled = false;
};
@@ -0,0 +1,18 @@
#pragma once
#include "CoreMinimal.h"
#include "Modules/ModuleManager.h"
ASSETUSAGEAUDITCORE_API DECLARE_LOG_CATEGORY_EXTERN(LogAssetUsageAudit, Log, All);
/**
* Analysis-only module. Deliberately links no UI and no editor framework:
* every dependency here is available in a commandlet and in a standalone Program,
* which is what keeps a future headless mode a packaging question rather than a rewrite.
*/
class FAssetUsageAuditCoreModule : public IModuleInterface
{
public:
virtual void StartupModule() override;
virtual void ShutdownModule() override;
};
@@ -0,0 +1,235 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "Misc/AssetRegistryInterface.h"
// EExportLayout is a UENUM so the editor settings can expose it as a dropdown. It is the only
// reflected type in this module; everything else here stays plain C++ so the analysis has no
// reflection cost. Must be the last include, as UHT requires.
#include "AssetUsageAuditTypes.generated.h"
/**
* Why an asset is considered used - or why we cannot tell.
*
* Deliberately five states, never a bool. The Asset Registry cannot see string-built paths,
* OpenLevel(FName), DataTable row contents or FMOD event references; reporting those as
* "unused" is how an audit tool causes a deletion incident. Unknown is a legitimate answer.
*/
enum class EAssetUsageVerdict : uint8
{
/** Reachable from at least one level package. Columns say through which, and hard or soft. */
UsedOnLevel,
/** Has referencers, but no chain from any level reaches it. */
UsedByAssetsOnly,
/** Found by scanning Config/ and Source/ for /Game paths. Provenance carries file and line. */
ReferencedFromConfigOrSource,
/** GetReferencers returned nothing and no level reaches it. */
Unreferenced,
/** Falls into a known registry blind spot. Never present this as "safe to delete". */
Unknown
};
ASSETUSAGEAUDITCORE_API const TCHAR* LexToString(EAssetUsageVerdict Verdict);
/**
* Every verdict, in the order the UI should list them.
*
* Single source of truth: the menu, the report and the settings restore all need this list, and
* three hand-written copies would drift the moment a sixth verdict is added.
*/
ASSETUSAGEAUDITCORE_API TArray<EAssetUsageVerdict> GetAllAssetUsageVerdicts();
/** How an asset reference was discovered. Bitmask - an asset can be reached several ways. */
enum class EAssetUsageProvenance : uint8
{
None = 0,
/** Direct hard dependency edge (EDependencyProperty::Hard present). */
HardReference = 1 << 0,
/** Soft dependency edge - the lack of Hard. Includes every external actor edge. */
SoftReference = 1 << 1,
/** Reached through an OFPA __ExternalActors__ / __ExternalObjects__ package. */
ExternalActor = 1 << 2,
/** Reached through ULevelStreaming::WorldAsset or ALevelInstance::WorldAsset. */
Sublevel = 1 << 3,
/** Path literal found in Config/*.ini. */
ConfigFile = 1 << 4,
/** Path literal found in Source/**.cpp|h. */
SourceFile = 1 << 5,
/** Edge is editor-only (EDependencyProperty::Game absent) - not shipped, but still a use. */
EditorOnly = 1 << 6,
/** Reached through a redirector that we resolved. */
Redirector = 1 << 7
};
ENUM_CLASS_FLAGS(EAssetUsageProvenance);
ASSETUSAGEAUDITCORE_API FString ProvenanceToString(EAssetUsageProvenance Provenance);
/**
* How exported files are arranged under the target folder.
*
* Deliberately an enum rather than a set of booleans. These are mutually exclusive: "mirror the
* /Game tree" and "one folder per ticked asset" cannot both be true, and a pair of booleans would
* make that contradiction expressible and then silently resolve it one way.
*/
UENUM()
enum class EExportLayout : uint8
{
/** Everything straight into the target folder. This is why the collision policy exists. */
Flat UMETA(DisplayName = "Flat - everything in one folder"),
/** Mirror the package path, so /Game/Art/SM_Rock lands in Game/Art/SM_Rock.uasset. */
MirrorTree UMETA(DisplayName = "Mirror the /Game folder tree"),
/**
* A subfolder per ticked asset, with that asset's dependencies beside it.
*
* Note the consequence: a texture shared by forty meshes is copied forty times, once into each
* mesh's folder. That is the point - each folder is self-contained and can be handed over on
* its own - but it means the file count exceeds the number of distinct assets, sometimes by a
* lot. The export dialog states the real figure before anything is written.
*/
FolderPerAsset,
/**
* Hand the packages to IAssetTools::MigratePackages, aimed at another project's Content folder.
*
* The only layout whose output opens in Unreal with its references intact. References inside a
* .uasset are stored as full package names, so a file only resolves when it sits at exactly the
* same package path in the destination; Migrate is the engine's own code for arranging that,
* including the OFPA actor packages of a level.
*
* Not implemented in this module. AssetTools is editor-only, and the analysis here has to stay
* runnable from a commandlet - the editor module dispatches this value to its own path. Anything
* in Core that switches on the layout must therefore treat Migrate as "not mine".
*/
Migrate
};
ASSETUSAGEAUDITCORE_API const TCHAR* LexToString(EExportLayout Layout);
/**
* What to do when an exported file already exists in the target folder.
*/
enum class EExportCollisionPolicy : uint8
{
/** Replace the existing file. */
Overwrite,
/** Append or increment a numeric suffix: Foo -> Foo_1, Foo_7 -> Foo_8. */
Index
};
/**
* One row of the audit result.
*/
struct ASSETUSAGEAUDITCORE_API FAssetUsageRow
{
/** Package name, e.g. /Game/Space/Art/SM_Rock. */
FName PackageName;
/** Asset name without path. */
FName AssetName;
/** Class path of the asset, e.g. /Script/Engine.StaticMesh. */
FTopLevelAssetPath ClassPath;
/**
* For Blueprints, the generated gameplay class from the GeneratedClass tag.
* A BP asset's ClassPath is always /Script/Engine.Blueprint, which is useless for filtering.
*/
FTopLevelAssetPath GeneratedClassPath;
/** Path relative to the project root, e.g. Content/Space/Art/SM_Rock.uasset. */
FString PathFromProjectRoot;
EAssetUsageVerdict Verdict = EAssetUsageVerdict::Unknown;
EAssetUsageProvenance Provenance = EAssetUsageProvenance::None;
/** Levels this asset is reachable from. Includes both the sublevel and its parent map. */
TArray<FName> Levels;
/** How many incoming edges carried EDependencyProperty::Hard. */
int32 HardReferenceCount = 0;
/** How many incoming edges lacked Hard. */
int32 SoftReferenceCount = 0;
/**
* Human-readable chain explaining the verdict, e.g.
* "WP_Main -> __ExternalActors__/.../A2B -> BP_Rock -> SM_Rock".
* A verdict without a route is unactionable for an artist.
*/
FString Route;
/** For ReferencedFromConfigOrSource - which file and line named this asset. */
FString ProvenanceDetail;
bool IsUsedOnAnyLevel() const
{
return Verdict == EAssetUsageVerdict::UsedOnLevel;
}
};
/**
* Metadata written into the report header so a stale report can never be mistaken for a fresh one.
*/
struct ASSETUSAGEAUDITCORE_API FAssetUsageReportHeader
{
FDateTime GeneratedAt;
FString EngineVersion;
FString ToolVersion;
FString ProjectName;
/** Human-readable description of the filters that produced this result. */
TArray<FString> AppliedFilters;
int32 LevelsScanned = 0;
int32 AssetsScanned = 0;
double ScanDurationSeconds = 0.0;
};
/**
* The dependency query this tool must always use.
*
* Package category, NoRequirements flags. NOT Hard.
*
* Two engine facts make this non-negotiable:
*
* 1. FExternalObjectAndActorDependencyGatherer (ExternalObjectAndActorDependencyGatherer.cpp:22)
* emits map -> external actor edges with property mask Game|Build. Hard is absent, and
* AssetRegistryInterface.h:95 states the lack of Hard *is* a soft dependency. Querying with
* Hard therefore drops every external actor - 16126 packages in this project.
*
* 2. In EDependencyQuery, Soft is literally defined as NotHard. So Hard|Soft means
* "require Hard AND require not-Hard" and matches nothing at all.
*
* Hard vs soft is a column in the report, never a filter on the query.
*/
namespace AssetUsageAudit
{
inline UE::AssetRegistry::FDependencyQuery MakeTraversalQuery()
{
return UE::AssetRegistry::FDependencyQuery();
}
inline UE::AssetRegistry::EDependencyCategory MakeTraversalCategory()
{
return UE::AssetRegistry::EDependencyCategory::Package;
}
}
@@ -0,0 +1,85 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "AssetUsageAuditTypes.h"
#include "AssetTypeFilter.h"
#include "AssetUsageGraph.h"
#include "LevelUsageResolver.h"
class IAssetRegistry;
struct FAssetUsageAuditRequest
{
/** Roots to sweep. Empty means /Game. */
TArray<FString> IncludePackagePaths;
/** Package or content-relative prefixes to skip, e.g. "Content/3rdParty". */
TArray<FString> ExcludePackagePaths;
/** Levels to consider. Empty means every level found. */
TArray<FName> LevelPackages;
/** Type filter applied to the reported rows, not to the traversal. */
FAssetTypeFilter TypeFilter;
/** Scan Config/ and Source/ for path literals the registry cannot see. */
bool bScanIndirectReferences = true;
/** Drop rows for __ExternalActors__ / __ExternalObjects__ packages. */
bool bHideExternalPackages = true;
/** Drop redirector rows; they are plumbing, not content an artist acts on. */
bool bHideRedirectors = true;
/** Report only assets that no level reaches. */
bool bOnlyUnusedAssets = false;
FLevelUsageResolveOptions ResolveOptions;
};
// Exported: CountByVerdict is defined out of line, so the Editor module cannot link without this.
struct ASSETUSAGEAUDITCORE_API FAssetUsageAuditResult
{
TArray<FAssetUsageRow> Rows;
FAssetUsageReportHeader Header;
FAssetUsageGraphStats GraphStats;
FLevelUsageStats LevelStats;
int32 CountByVerdict(EAssetUsageVerdict Verdict) const;
};
/**
* Runs the whole analysis: graph, level reachability, indirect scan, verdicts.
*
* Free of UI and of the editor asset pipeline, so the Slate panel and a future commandlet can
* call exactly the same code and cannot drift apart.
*/
class ASSETUSAGEAUDITCORE_API FAssetUsageAuditor
{
public:
static FAssetUsageAuditResult Run(IAssetRegistry& AssetRegistry, FAssetUsageAuditRequest& Request);
/**
* Classify one asset.
*
* Pure and exposed for testing: the ordering of these rules is the difference between a tool
* people trust and one that tells an artist to delete the GameMode.
*
* @param bReachableFromLevel A level's traversal reached this asset.
* @param bHasReferencers Something in the graph depends on it.
* @param bFoundInConfigOrSource A path literal in Config/ or Source/ named it.
* @param bIsBlindSpot Its type is one the registry cannot track reliably, e.g. FMOD.
*/
static EAssetUsageVerdict ClassifyVerdict(bool bReachableFromLevel, bool bHasReferencers, bool bFoundInConfigOrSource, bool bIsBlindSpot);
/**
* True for asset types whose real usage the Asset Registry cannot see.
*
* FMOD is the concrete case on this project: it resolves events by string path through the
* FMOD Studio runtime, entirely outside the UObject reference graph, so "no referencers"
* carries no information at all for an FMOD asset.
*/
static bool IsRegistryBlindSpot(const FAssetUsageNode& Node);
};
@@ -0,0 +1,219 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "AssetUsageAuditTypes.h"
/**
* Copies package files out of the project.
*
* Deliberately a plain file copy rather than IAssetTools::MigratePackages. Migrate pulls the whole
* dependency closure, which is emphatically not what someone asked for when they ticked eleven
* meshes, and it lives in an editor-only module. A byte copy loads no UObject, needs no editor,
* and gives exactly the files that were ticked.
*/
class ASSETUSAGEAUDITCORE_API FAssetUsageExporter
{
public:
struct FOptions
{
/** Absolute destination directory. Created if missing. */
FString TargetDirectory;
/** What to do when a file of that name is already there. */
EExportCollisionPolicy CollisionPolicy = EExportCollisionPolicy::Index;
/**
* Put every file directly in the target folder rather than mirroring the /Game tree.
*
* Flat is what people expect from "export to a folder" and is why the collision policy
* exists at all - mirroring the tree makes collisions nearly impossible but hands back a
* deep folder structure nobody asked for.
*/
bool bFlatten = true;
/**
* Also copy the __ExternalActors__ / __ExternalObjects__ packages belonging to any exported
* level. Without these an exported OFPA map opens empty in the destination project.
*/
bool bIncludeExternalPackages = true;
/**
* Record where each package actually ended up, in FResult::WrittenFiles.
*
* Off by default because it costs a string per file. Required by the dependency manifest:
* a manifest built from intended paths rather than written ones is wrong precisely when the
* collision policy renamed something, which is when someone needs it most.
*/
bool bRecordWrittenFiles = false;
/** Called as (Done, Total). Return false to cancel; already-copied files are kept. */
TFunction<bool(int32, int32)> OnProgress;
};
/** One copied file, as it was actually written. */
struct FWrittenFile
{
FName PackageName;
/** Path relative to FOptions::TargetDirectory, with the final on-disk filename. */
FString RelativePath;
};
// Exported: Summarise is defined out of line, so the Editor module cannot link without this.
struct ASSETUSAGEAUDITCORE_API FResult
{
/** Filled only when FOptions::bRecordWrittenFiles was set. */
TArray<FWrittenFile> WrittenFiles;
bool bSuccess = false;
int32 FilesCopied = 0;
int32 FilesRenamed = 0;
int32 FilesOverwritten = 0;
int32 FilesMissingOnDisk = 0;
int32 ExternalPackagesCopied = 0;
bool bCancelled = false;
/** One line per failure, safe to show in a dialog. */
TArray<FString> Errors;
FString Summarise() const;
};
/**
* A set of packages destined for one subfolder of the target.
*
* The exporter does not decide what the subfolder means - AssetExportLayout does, and hands
* the result over already named. Keeping the split means this class still knows nothing about
* presets, dependencies or the Asset Registry, which is why its specs can run on package names
* that do not exist.
*/
struct FExportGroup
{
/** Relative to FOptions::TargetDirectory. Empty writes straight into it. */
FString RelativeDir;
TArray<FName> Packages;
};
/**
* Copy packages, each group into its own subfolder.
*
* Collision handling is per destination folder, not global: two groups may each hold a file
* called SM_Rock.uasset without either being renamed, because they land in different folders.
* That is the point of the folder-per-asset layout.
*/
static FResult ExportPackageGroups(const TArray<FExportGroup>& Groups, const FOptions& Options);
/** Single-group convenience: everything straight into the target. */
static FResult ExportPackageFiles(const TArray<FName>& PackageNames, const FOptions& Options);
// --- Exchange formats ------------------------------------------------------------------------
/**
* One asset to convert. Deliberately not FAssetUsageRow: conversion needs three fields and
* taking the whole row would make this callable only from a finished audit.
*/
struct FExportItem
{
FName PackageName;
FName AssetName;
/** Asset class, e.g. /Script/Engine.StaticMesh. Chooses the output format. */
FTopLevelAssetPath ClassPath;
};
struct FExchangeOptions
{
FString TargetDirectory;
EExportCollisionPolicy CollisionPolicy = EExportCollisionPolicy::Index;
bool bFlatten = true;
/**
* Asset class short name -> file extension without the dot, e.g. {"StaticMesh", "fbx"}.
*
* Passed in rather than hardcoded so the Core module stays free of the settings object, and
* so a class with no sensible exchange format is a configuration fact rather than a silent
* omission. Lookup walks up the class hierarchy, so mapping MaterialInterface also covers
* MaterialInstanceConstant.
*/
TMap<FString, FString> FormatByClass;
/**
* Run garbage collection every N assets.
*
* Unlike a file copy, this path loads every UObject it touches. Exporting a few thousand
* meshes without collecting will exhaust memory long before the export finishes. Zero
* disables it.
*/
int32 CollectGarbageEvery = 64;
/** Called as (Done, Total). Return false to cancel; files already written are kept. */
TFunction<bool(int32, int32)> OnProgress;
};
struct ASSETUSAGEAUDITCORE_API FExchangeResult
{
bool bSuccess = false;
int32 FilesWritten = 0;
int32 FilesRenamed = 0;
/** No extension configured for the asset's class. Not an error - a deliberate omission. */
int32 SkippedNoFormat = 0;
/** The asset would not load. Counted separately from an exporter refusing to run. */
int32 SkippedNotLoaded = 0;
/** Loaded fine, but no UExporter is registered for that class and extension. */
int32 SkippedNoExporter = 0;
bool bCancelled = false;
TArray<FString> Errors;
FString Summarise() const;
};
/**
* Convert assets to interchange formats (FBX, PNG, WAV, ...) via UAssetExportTask.
*
* Loads every asset, so it is orders of magnitude slower than ExportPackageFiles and must be
* driven with a progress callback. bPrompt is forced false and bAutomated true: a modal file
* dialog per asset would hang an unattended run, and this is the whole reason the engine has
* those flags.
*/
static FExchangeResult ExportConvertedAssets(const TArray<FExportItem>& Items, const FExchangeOptions& Options);
/**
* Extension for an asset class, following the class hierarchy upward.
* Returns an empty string when nothing in the chain is mapped.
*/
static FString FindFormatForClass(const FTopLevelAssetPath& ClassPath, const TMap<FString, FString>& FormatByClass);
/** The mapping the tool ships with. Editor settings seed themselves from this. */
static TMap<FString, FString> GetDefaultFormatByClass();
/**
* Resolve a package name to its file on disk, trying both asset and map extensions.
* Returns an empty string when the package has no file (script packages, unsaved assets).
*/
static FString ResolvePackageFilePath(FName PackageName);
private:
/**
* The copy loop, shared by every entry point.
*
* Takes the destination folder per entry rather than deriving it, so that deciding where a file
* goes and actually writing it stay separate concerns. Result is passed by reference because
* the caller has already recorded setup failures into it.
*/
static FResult CopyExpandedPackages(
const TArray<FName>& Expanded,
const TArray<FString>& DestinationDirs,
const TBitArray<>& IsExternalExpansion,
const FOptions& Options,
FResult& Result);
};
@@ -0,0 +1,188 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "AssetUsageAuditTypes.h"
#include "AssetRegistry/AssetData.h"
class IAssetRegistry;
/**
* One dependency edge, stored by dense index rather than by FName.
*
* 8 bytes. At this project's scale (80k packages, several hundred thousand edges) the difference
* between this and a TMap<FName, TArray<FName>> is the difference between a tool that answers in
* seconds and one nobody waits for.
*/
struct FAssetUsageEdge
{
/** Dense index of the target package in FAssetUsageGraph. */
int32 TargetIndex = INDEX_NONE;
/** Edge properties as reported by the registry: Hard / Game / Build. */
UE::AssetRegistry::EDependencyProperty Properties = UE::AssetRegistry::EDependencyProperty::None;
bool IsHard() const
{
return EnumHasAnyFlags(Properties, UE::AssetRegistry::EDependencyProperty::Hard);
}
/** Lack of Hard is what the engine calls a soft dependency (AssetRegistryInterface.h:95). */
bool IsSoft() const
{
return !IsHard();
}
/** Lack of Game means the edge is editor-only and will not survive a cook. */
bool IsEditorOnly() const
{
return !EnumHasAnyFlags(Properties, UE::AssetRegistry::EDependencyProperty::Game);
}
};
/**
* Per-package facts captured once during the sweep, so later passes never re-query the registry.
*/
struct FAssetUsageNode
{
FName PackageName;
FName AssetName;
FTopLevelAssetPath ClassPath;
/**
* Gameplay class behind a Blueprint, read from the GeneratedClass asset-registry tag.
*
* Needed because a BP asset's own ClassPath is always /Script/Engine.Blueprint. Filtering
* ClassPaths alone finds zero Blueprints, which in this project means finding almost nothing.
*/
FTopLevelAssetPath GeneratedClassPath;
/** ClassPath is /Script/Engine.World. */
bool bIsLevel = false;
/** Package lives under __ExternalActors__ or __ExternalObjects__. */
bool bIsExternalPackage = false;
/** Asset is an ObjectRedirector and must be followed through, not reported. */
bool bIsRedirector = false;
};
struct FAssetUsageGraphBuildOptions
{
/** Roots to sweep. Defaults to /Game. */
TArray<FString> IncludePackagePaths;
/** Package path prefixes to skip entirely, e.g. Content/3rdParty. */
TArray<FString> ExcludePackagePaths;
/** Collect dependency edges. Off gives a much faster inventory-only pass. */
bool bGatherDependencies = true;
};
struct FAssetUsageGraphStats
{
int32 NumPackages = 0;
int32 NumEdges = 0;
int32 NumLevels = 0;
int32 NumExternalPackages = 0;
double EnumerateSeconds = 0.0;
double DependencySeconds = 0.0;
double TotalSeconds() const
{
return EnumerateSeconds + DependencySeconds;
}
};
/**
* Dense, immutable-after-build dependency graph over the project's packages.
*
* Edges are stored CSR-style: one flat FAssetUsageEdge array plus an offset table, so a node's
* dependency list is a contiguous view with no per-node allocation.
*
* The graph deliberately stores dependencies in BOTH directions. Forward edges answer "what does
* this level use"; reverse edges answer "is this asset referenced by anything at all", which is
* what separates the Unreferenced verdict from UsedByAssetsOnly.
*/
class ASSETUSAGEAUDITCORE_API FAssetUsageGraph
{
public:
/**
* Sweep the registry and build the graph.
*
* The registry must already be populated. In a commandlet the AssetRegistry module gathers
* synchronously on load; in the editor callers must wait for OnFilesLoaded first.
*/
void Build(IAssetRegistry& AssetRegistry, const FAssetUsageGraphBuildOptions& Options);
void Reset();
int32 Num() const
{
return Nodes.Num();
}
bool IsValidIndex(int32 Index) const
{
return Nodes.IsValidIndex(Index);
}
/** INDEX_NONE when the package was not part of the sweep. */
int32 FindPackageIndex(FName PackageName) const
{
const int32* Found = PackageToIndex.Find(PackageName);
return Found ? *Found : INDEX_NONE;
}
const FAssetUsageNode& GetNode(int32 Index) const
{
return Nodes[Index];
}
TConstArrayView<FAssetUsageNode> GetNodes() const
{
return Nodes;
}
/** Packages this one depends on. */
TConstArrayView<FAssetUsageEdge> GetDependencies(int32 Index) const;
/** Packages that depend on this one. */
TConstArrayView<FAssetUsageEdge> GetReferencers(int32 Index) const;
/** Dense indices of every /Script/Engine.World package in the sweep. */
TConstArrayView<int32> GetLevelIndices() const
{
return LevelIndices;
}
const FAssetUsageGraphStats& GetStats() const
{
return Stats;
}
/**
* Follow a redirector chain to the asset it ultimately points at.
* Returns Index unchanged when it is not a redirector. Cycle-safe.
*/
int32 ResolveRedirector(int32 Index) const;
private:
int32 AddOrFindPackage(FName PackageName);
void BuildReverseEdges();
TArray<FAssetUsageNode> Nodes;
TMap<FName, int32> PackageToIndex;
/** CSR forward edges: Dependencies[DependencyOffsets[i] .. DependencyOffsets[i+1]). */
TArray<FAssetUsageEdge> Dependencies;
TArray<int32> DependencyOffsets;
TArray<FAssetUsageEdge> Referencers;
TArray<int32> ReferencerOffsets;
TArray<int32> LevelIndices;
FAssetUsageGraphStats Stats;
};
@@ -0,0 +1,81 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
/**
* Package-path predicates and conversions.
*
* Pure string work, no registry and no engine state, so it is unit-testable and cheap enough to
* call inside the per-asset sweep.
*
* Note on OFPA: these helpers only ever *classify* a path. They never try to derive a level's
* external-actor folder by string building, and they never try to recover the owning level from
* an external actor path. Both are real traps - Content Bundles inject /CB/<Guid>/ and External
* Data Layers inject /EDL/<UID>/ between the folder and the level path, and plugins can register
* additional roots through delegates. Producing those paths is ULevel::GetExternalActorsPaths's
* job; recovering ownership is avoided entirely by traversing forward from the level.
*/
namespace AssetUsagePaths
{
/** True for a package under an __ExternalActors__ or __ExternalObjects__ root. */
ASSETUSAGEAUDITCORE_API bool IsExternalPackage(FName PackageName);
ASSETUSAGEAUDITCORE_API bool IsExternalPackage(FStringView PackagePath);
/** True for /Script/... - code, not an asset. Cannot be exported or reported unused. */
ASSETUSAGEAUDITCORE_API bool IsScriptPackage(FName PackageName);
ASSETUSAGEAUDITCORE_API bool IsScriptPackage(FStringView PackagePath);
/** True for /Engine/... or /Temp/... - not project content. */
ASSETUSAGEAUDITCORE_API bool IsEngineOrTempPackage(FStringView PackagePath);
/**
* True when the package sits under any of the given exclusion prefixes.
*
* Prefixes may be given in either package form ("/Game/3rdParty") or content-relative form
* ("Content/3rdParty"), because the UI shows users the latter and settings files tend to
* accumulate both. Matching is case-insensitive and boundary-aware, so "/Game/Art" does not
* exclude "/Game/ArtSource".
*/
ASSETUSAGEAUDITCORE_API bool IsPathExcluded(FName PackageName, const TArray<FString>& ExcludedPrefixes);
ASSETUSAGEAUDITCORE_API bool IsPathExcluded(FStringView PackagePath, const TArray<FString>& ExcludedPrefixes);
/**
* Normalise an exclusion prefix to package form with no trailing slash.
* "Content/3rdParty/" -> "/Game/3rdParty"
* "/Game/3rdParty" -> "/Game/3rdParty"
* "Content" -> "/Game"
*/
ASSETUSAGEAUDITCORE_API FString NormalizeExclusionPrefix(const FString& Prefix);
/**
* Package name to a path relative to the project root, as the report requires.
* "/Game/Space/Art/SM_Rock" -> "Content/Space/Art/SM_Rock.uasset"
*
* @param bIsLevel Chooses the .umap extension over .uasset.
* @return Empty for packages with no project-relative form, e.g. /Script or /Engine.
*/
ASSETUSAGEAUDITCORE_API FString ToProjectRelativePath(FName PackageName, bool bIsLevel);
/**
* Can this folder be used as a Migrate destination?
*
* Mirrors the two checks UAssetToolsImpl makes, and exists because it makes them *after* being
* called and reports the refusal to the Output Log alone - so an impossible export otherwise
* presents as a button that does nothing.
*
* The engine's rules:
* 1. the path must end in /Content/ (MigratePackages_ReportConfirmed);
* 2. the folder above it must hold a .uproject, or exactly one .uplugin
* (FPackageMigrationImpl::GetMountPointRootPath) - that is where the destination mount
* point comes from, and without it Migrate aborts.
*
* Lives in Core despite serving an editor-only feature: it is filesystem and string work with
* no AssetTools involved, so it belongs with the other path predicates and can be tested
* without an editor module.
*
* @return Reason the folder is unusable, or an empty string when it is fine.
*/
ASSETUSAGEAUDITCORE_API FString ValidateMigrateDestination(const FString& Directory);
}
@@ -0,0 +1,68 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
struct FAssetUsageAuditResult;
/**
* Writes the audit result as JSON and CSV.
*
* Both come from one in-memory result in a single call, so the two files can never disagree
* about the same run. JSON is the machine-readable form (nested level lists, filter metadata);
* CSV is the flat form a human opens in Excel.
*/
class ASSETUSAGEAUDITCORE_API FAssetUsageReportWriter
{
public:
struct FOptions
{
/** Absolute directory to write into. Created if missing. */
FString OutputDirectory;
/** Base filename without extension; ".json" and ".csv" are appended. */
FString BaseFileName = TEXT("AssetUsageReport");
bool bWriteJson = true;
bool bWriteCsv = true;
/**
* Write a UTF-8 BOM at the start of the CSV.
*
* Excel misreads UTF-8 without it and mangles every non-ASCII asset name, which on this
* project means the Cyrillic folder and asset names come out as garbage.
*/
bool bCsvUtf8Bom = true;
/** CSV field separator. Semicolon suits locales where the comma is a decimal separator. */
TCHAR CsvDelimiter = TEXT(';');
/** Separator for multi-valued fields inside one CSV cell, e.g. the level list. */
FString CsvMultiValueSeparator = TEXT("|");
};
struct FResult
{
bool bSuccess = false;
FString JsonPath;
FString CsvPath;
FString ErrorMessage;
};
static FResult Write(const FAssetUsageAuditResult& AuditResult, const FOptions& Options);
/** Serialize to a JSON string without touching disk. Exposed for tests. */
static FString BuildJson(const FAssetUsageAuditResult& AuditResult);
/** Serialize to a CSV string without touching disk. Exposed for tests. */
static FString BuildCsv(const FAssetUsageAuditResult& AuditResult, const FOptions& Options);
/**
* Quote and escape one CSV field per RFC 4180.
*
* Exposed because this is where CSV writers usually break: a field containing the delimiter,
* a quote or a newline must be quoted, and embedded quotes doubled.
*/
static FString EscapeCsvField(const FString& Field, TCHAR Delimiter);
};
@@ -0,0 +1,66 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
/**
* One /Game path literal found in a text file, with enough context to check it by hand.
*/
struct FIndirectReference
{
/** Package name the literal resolved to, e.g. /Game/Space/Core/GameModes/BP_FirstPersonGameMode. */
FName PackageName;
/** Absolute path of the file the literal was found in. */
FString SourceFile;
/** 1-based line number. */
int32 LineNumber = 0;
/** True when the hit came from Config/, false when from Source/. */
bool bFromConfig = false;
FString ToProvenanceString() const;
};
/**
* Finds asset paths referenced from text rather than from a package.
*
* This exists because of a concrete, measured failure mode on this project: Config/DefaultEngine.ini
* names BP_FirstPersonGameMode, BP_MenuSystemGameInstance, RefinedMenuMap and Gyms_Geoda as plain
* strings. No asset references them, so the Asset Registry reports zero referencers and a naive
* audit calls the project's GameMode unused.
*
* It is a mitigation, not a solution. It cannot see a path assembled at runtime by concatenation,
* an OpenLevel(FName) call, or an FMOD event path - those stay Unknown, deliberately.
*/
class ASSETUSAGEAUDITCORE_API FIndirectReferenceScanner
{
public:
struct FOptions
{
/** Absolute directories to scan. Defaults to <Project>/Config and <Project>/Source. */
TArray<FString> Directories;
/** File extensions to read, lowercase, with the dot. */
TArray<FString> Extensions = { TEXT(".ini"), TEXT(".cpp"), TEXT(".h"), TEXT(".cs") };
/** Skip files larger than this; a multi-megabyte generated file is never a reference site. */
int64 MaxFileSizeBytes = 8 * 1024 * 1024;
};
/** Scan and return every distinct package path found, with provenance. */
static TArray<FIndirectReference> Scan(const FOptions& Options);
/** Default options: <Project>/Config and <Project>/Source. */
static FOptions MakeDefaultOptions();
/**
* Extract /Game path literals from one line of text.
*
* Exposed for testing: the trailing-_C strip and the delimiter set are where this goes wrong.
* "/Game/X/BP_Y.BP_Y_C" and "/Game/X/BP_Y" must both yield the package /Game/X/BP_Y.
*/
static void ExtractGamePathsFromLine(FStringView Line, TArray<FString>& OutPackageNames);
};
@@ -0,0 +1,154 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "AssetUsageAuditTypes.h"
#include "Containers/BitArray.h"
class FAssetUsageGraph;
class IAssetRegistry;
struct FLevelUsageResolveOptions
{
/** Levels to analyse. Empty means every /Script/Engine.World package in the graph. */
TArray<FName> LevelPackages;
/**
* Call ULevel::ScanLevelAssets before traversing each level.
*
* The external-actor dependency gatherer only reports packages the registry has already
* scanned. Skipping this can silently yield an empty external-actor set on a level nobody
* has opened this session - which looks exactly like a level that genuinely has no actors.
*/
bool bScanLevelAssetsFirst = true;
/**
* Additionally seed the BFS from ULevel::GetExternalActorsPaths / GetExternalObjectsPaths.
*
* Belt and braces: the gatherer normally puts these edges in the graph already. Keeping the
* explicit seed means a stale or partially-scanned registry degrades to a slower correct
* answer instead of a fast wrong one, and it lets us tag ExternalActor provenance precisely.
*/
bool bSeedExternalPackages = true;
/**
* Follow a reference into another map's contents.
*
* Off by default, and that default is load-bearing. A map does not only reference its own
* sublevels: anything it can reach may name an unrelated map, and on this project it does.
* WP_Main -> BP_FirstPersonGameMode -> PDA_MenuSystemConfig -> L_MainLevel drags in the whole
* of L_MainLevel, which measured at 9994 of WP_Main's 18136 rows - two thirds of the answer
* was another level's content.
*
* With this off, a foreign map is still reported as referenced, but its contents are attributed
* to that map alone. Genuine sublevels and Level Instances are unaffected: they are reached
* from the level package itself or from one of its external actor packages, and that crossing
* is always allowed. See ShouldCrossIntoLevel in the .cpp.
*/
bool bTraverseIntoOtherLevels = false;
/** Record a human-readable route for each asset. Costs one int32 array per BFS. */
bool bRecordRoutes = true;
/** Route strings longer than this many hops are elided in the middle. */
int32 MaxRouteHops = 12;
/** Optional progress sink, called once per level with (LevelIndex, TotalLevels). */
TFunction<void(int32, int32)> OnLevelProgress;
/** Return true to abort the sweep between levels. */
TFunction<bool()> ShouldAbort;
};
struct FLevelUsageStats
{
int32 LevelsScanned = 0;
int32 AssetsReachable = 0;
int32 ExternalPackagesSeeded = 0;
int32 RedirectorsResolved = 0;
/**
* Foreign maps referenced but deliberately not expanded into.
*
* A non-zero value here is the amount of another level's content that would otherwise have
* been attributed to this one. Worth surfacing: on WP_Main it was two thirds of the report.
*/
int32 ForeignLevelsNotExpanded = 0;
double ScanLevelAssetsSeconds = 0.0;
double TraversalSeconds = 0.0;
double TotalSeconds() const
{
return ScanLevelAssetsSeconds + TraversalSeconds;
}
};
/**
* Which levels reach which assets.
*
* Reachability is one TBitArray per level over dense package indices. At this project's scale
* that is 1207 levels x 80608 bits, roughly 12 MB - cheap enough to hold both query directions
* ("assets on this level" and "levels using this asset") without ever re-walking the graph.
*/
struct ASSETUSAGEAUDITCORE_API FLevelUsageResult
{
/** Level package names, parallel to LevelReachability. */
TArray<FName> LevelPackageNames;
/** LevelReachability[L][A] - level L reaches asset A. */
TArray<TBitArray<>> LevelReachability;
/** Union across every level. The primary input to the UsedOnLevel verdict. */
TBitArray<> ReachableFromAnyLevel;
/** Accumulated provenance flags per asset index. */
TArray<EAssetUsageProvenance> Provenance;
/** Incoming edge counts per asset index, split by the Hard property. */
TArray<int32> HardReferenceCounts;
TArray<int32> SoftReferenceCounts;
/** Route string per asset index, from the first level that reached it. Empty when not recorded. */
TMap<int32, FString> Routes;
FLevelUsageStats Stats;
/** Levels that reach the given asset index. */
TArray<FName> GetLevelsForAsset(int32 AssetIndex) const;
bool IsReachableFromAnyLevel(int32 AssetIndex) const
{
return ReachableFromAnyLevel.IsValidIndex(AssetIndex) && ReachableFromAnyLevel[AssetIndex];
}
};
/**
* Forward traversal from level packages to everything they pull in.
*
* Direction matters. Going forward from the map is correct; going backward from an external actor
* package to its owning map is not, because PackageDependencyData.cpp:57-96 deliberately strips
* the UsedInGame flag off that reverse import by naming convention, so the AssetManager will not
* drag a whole map in when something references one actor. Reconstructing ownership from the path
* instead is possible but fragile - Content Bundles inject /CB/<Guid>/ and External Data Layers
* inject /EDL/<UID>/ between the folder and the level path. Forward traversal sidesteps all of it.
*/
class ASSETUSAGEAUDITCORE_API FLevelUsageResolver
{
public:
FLevelUsageResolver(const FAssetUsageGraph& InGraph, IAssetRegistry& InAssetRegistry);
FLevelUsageResult Resolve(const FLevelUsageResolveOptions& Options);
private:
/** BFS from one level. Marks OutReachable and accumulates provenance and counts. */
void TraverseLevel(int32 LevelIndex, const FLevelUsageResolveOptions& Options, TBitArray<>& OutReachable, FLevelUsageResult& InOutResult, TArray<int32>& ScratchPredecessor, TArray<int32>& ScratchQueue);
/** Dense indices of the __ExternalActors__ / __ExternalObjects__ packages owned by a level. */
void GatherExternalPackageSeeds(int32 LevelIndex, TArray<int32>& OutSeeds, FLevelUsageResult& InOutResult) const;
FString BuildRouteString(int32 LevelIndex, int32 AssetIndex, const TArray<int32>& Predecessor, int32 MaxHops) const;
const FAssetUsageGraph& Graph;
IAssetRegistry& AssetRegistry;
};