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:
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user