Compare commits

4 Commits

Author SHA1 Message Date
Admin 000cc5735c fix: correct authorship attribution to MagentaDolphin
The plugin was authored outside studio work but carried NextGenium
attribution in the .uplugin descriptor and in every source header.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-07 17:35:48 +07:00
Admin 569ec56a19 feat: import assets from an external folder
Reverse direction of the export: read a folder of .uasset files, recover
each one's original package path, and bring the chosen ones back.

Recovering the path is the hard part - a reference inside a .uasset names
its target by full package path, so a file only works when restored to
where it came from. Four sources are tried and the one used is shown per
row: our manifest, the package header, the folder structure, or nothing.

The package header carries the name on this project's assets, measured,
so a flat export without a manifest still restores correctly. The engine
notes the field is not always written, hence the chain rather than one
check.

Import refuses to destroy work: existing assets are skipped unless
overwrite is explicitly on, and a package loaded in the editor is never
replaced.

Also verifies Migrate on real data for the first time - asset and its
dependency land at the correct package paths.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-03 17:39:58 +07:00
Admin 2c82680a42 docs: record the version control arrangement
The plugin is its own repository with no remote yet. Writing down why
there is no .gitmodules entry: an entry without a working URL breaks
git submodule update --init for everyone running SetupProject.bat.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-03 17:10:29 +07:00
Admin 72bf94d142 feat: asset usage audit plugin
Editor tool for the LA and 3D departments: which assets each level uses,
which are used nowhere, and export of a chosen set.

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

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-03 17:07:59 +07:00
28 changed files with 384 additions and 2448 deletions
+315 -691
View File
File diff suppressed because it is too large Load Diff
@@ -1,243 +0,0 @@
// MagentaDolphin 2026. Asset Usage Audit.
#include "AssetDeletionScan.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "AssetUsageAuditTypes.h"
#include "AssetUsagePaths.h"
#include "Engine/World.h"
#include "HAL/FileManager.h"
#include "Misc/PackageName.h"
#define LOCTEXT_NAMESPACE "AssetDeletionScan"
namespace AssetDeletionScan
{
const TCHAR* LexToString(ERefusal Refusal)
{
switch (Refusal)
{
case ERefusal::None: return TEXT("None");
case ERefusal::IsLevel: return TEXT("IsLevel");
case ERefusal::IsExternalPackage: return TEXT("IsExternalPackage");
case ERefusal::NotProjectContent: return TEXT("NotProjectContent");
case ERefusal::Missing: return TEXT("Missing");
}
return TEXT("Unknown");
}
FText DescribeRefusal(ERefusal Refusal)
{
switch (Refusal)
{
case ERefusal::None:
return FText::GetEmpty();
case ERefusal::IsLevel:
return LOCTEXT("RefusalLevel",
"Levels are never deleted by this tool. Delete a map from the Content Browser if you really mean to.");
case ERefusal::IsExternalPackage:
return LOCTEXT("RefusalExternal",
"This is a single actor stored outside its map (One File Per Actor). Delete it in the level editor, where it can be undone.");
case ERefusal::NotProjectContent:
return LOCTEXT("RefusalNotProject",
"Engine or plugin content - not part of this project.");
case ERefusal::Missing:
return LOCTEXT("RefusalMissing",
"No asset found under this package name; nothing to delete.");
}
return FText::GetEmpty();
}
FString FStats::Summarise() const
{
return FString::Printf(
TEXT("%d ticked: %d deletable, %d refused (levels %d, external actors %d, engine %d, missing %d); ")
TEXT("%d still referenced from outside the set, %d read-only on disk."),
Requested,
Deletable,
Requested - Deletable,
RefusedLevels,
RefusedExternal,
RefusedNotProjectContent,
RefusedMissing,
WithOutsideReferencers,
ReadOnly);
}
TArray<FCandidate> Scan(
IAssetRegistry& Registry,
const TArray<FName>& Packages,
const FOptions& Options,
FStats& OutStats)
{
OutStats = FStats();
TArray<FCandidate> Candidates;
Candidates.Reserve(Packages.Num());
// Membership is needed while classifying referencers, so it is built first and in full.
// Deleting A and B where B is A's only referencer must not report A as still in use.
TSet<FName> DeleteSet(Packages);
// Answering "is this a map" costs a registry query that allocates; the same package shows up
// as a referencer of many candidates, so the answer is cached across the whole scan.
TMap<FName, bool> LevelCache;
const auto IsLevelPackage = [&Registry, &LevelCache](FName PackageName) -> bool
{
if (const bool* Cached = LevelCache.Find(PackageName))
{
return *Cached;
}
TArray<FAssetData> Assets;
Registry.GetAssetsByPackageName(PackageName, Assets, /*bIncludeOnlyOnDiskAssets=*/true);
bool bIsLevel = false;
for (const FAssetData& AssetData : Assets)
{
if (AssetData.AssetClassPath == UWorld::StaticClass()->GetClassPathName())
{
bIsLevel = true;
break;
}
}
LevelCache.Add(PackageName, bIsLevel);
return bIsLevel;
};
TArray<FName> Referencers;
TArray<FAssetData> Assets;
for (FName PackageName : Packages)
{
if (PackageName.IsNone())
{
continue;
}
++OutStats.Requested;
FCandidate Candidate;
Candidate.PackageName = PackageName;
const FString PackageString = PackageName.ToString();
// Filled from the path before any refusal, so a refused row still shows a name and a
// path. A row that says only "refused" with an empty name is not an explanation.
Candidate.AssetName = FName(*FPackageName::GetShortName(PackageString));
Candidate.PathFromProjectRoot = AssetUsagePaths::ToProjectRelativePath(PackageName, /*bIsLevel=*/false);
// Path-shaped refusals come first: they need no registry lookup, and two of them
// (script and engine content) would not produce a sensible FAssetData anyway.
if (AssetUsagePaths::IsScriptPackage(PackageName) || AssetUsagePaths::IsEngineOrTempPackage(PackageString))
{
Candidate.Refusal = ERefusal::NotProjectContent;
++OutStats.RefusedNotProjectContent;
Candidates.Add(MoveTemp(Candidate));
continue;
}
if (AssetUsagePaths::IsExternalPackage(PackageName))
{
Candidate.Refusal = ERefusal::IsExternalPackage;
++OutStats.RefusedExternal;
Candidates.Add(MoveTemp(Candidate));
continue;
}
Assets.Reset();
Registry.GetAssetsByPackageName(PackageName, Assets, /*bIncludeOnlyOnDiskAssets=*/true);
if (Assets.Num() == 0)
{
Candidate.Refusal = ERefusal::Missing;
++OutStats.RefusedMissing;
Candidates.Add(MoveTemp(Candidate));
continue;
}
// The package's own asset, preferring the one the package is named after. A package
// usually holds exactly one, but the first entry is not guaranteed to be the UAsset.
const FAssetData* Primary = Assets.FindByPredicate([](const FAssetData& Data) { return Data.IsUAsset(); });
const FAssetData& AssetData = Primary ? *Primary : Assets[0];
Candidate.AssetName = AssetData.AssetName;
Candidate.ClassPath = AssetData.AssetClassPath;
const bool bIsLevel = AssetData.AssetClassPath == UWorld::StaticClass()->GetClassPathName();
LevelCache.Add(PackageName, bIsLevel);
Candidate.PathFromProjectRoot = AssetUsagePaths::ToProjectRelativePath(PackageName, bIsLevel);
if (bIsLevel)
{
Candidate.Refusal = ERefusal::IsLevel;
++OutStats.RefusedLevels;
Candidates.Add(MoveTemp(Candidate));
continue;
}
// From here the candidate is deletable, and the remaining work is describing the
// consequences rather than deciding them.
Referencers.Reset();
Registry.GetReferencers(
PackageName, Referencers,
AssetUsageAudit::MakeTraversalCategory(), AssetUsageAudit::MakeTraversalQuery());
for (FName Referencer : Referencers)
{
if (Referencer == PackageName || DeleteSet.Contains(Referencer))
{
continue;
}
++Candidate.OutsideReferencerCount;
// An OFPA package is counted as a level reference without a registry lookup: it
// exists only to hold an actor placed in some map, so a reference from one means
// the asset is in a level right now.
if (AssetUsagePaths::IsExternalPackage(Referencer) || IsLevelPackage(Referencer))
{
++Candidate.LevelReferencerCount;
}
if (Candidate.OutsideReferencers.Num() < Options.MaxReferencersListed)
{
Candidate.OutsideReferencers.Add(Referencer);
}
}
FString Filename;
if (FPackageName::DoesPackageExist(PackageString, &Filename))
{
Candidate.bReadOnlyOnDisk = IFileManager::Get().IsReadOnly(*Filename);
}
++OutStats.Deletable;
if (Candidate.OutsideReferencerCount > 0)
{
++OutStats.WithOutsideReferencers;
}
if (Candidate.bReadOnlyOnDisk)
{
++OutStats.ReadOnly;
}
Candidates.Add(MoveTemp(Candidate));
}
return Candidates;
}
}
#undef LOCTEXT_NAMESPACE
@@ -252,8 +252,8 @@ namespace AssetDependencyClosure
// comes from a level or from one of its external actor packages - the shape a
// streaming sublevel or a Level Instance actually has. A World named by an ordinary
// asset is a different location that happens to be mentioned, and following it
// exports someone else's map: measured on WP_Example, the chain
// BP_GameMode -> PDA_MenuConfig -> L_Other accounts for 9994
// exports someone else's map: measured on WP_Main, the chain
// BP_FirstPersonGameMode -> PDA_MenuSystemConfig -> L_MainLevel accounts for 9994
// packages. FLevelUsageResolver applies the same rule; the two must not disagree
// about the same graph.
if (IsLevelPackage(Dependency))
@@ -170,7 +170,7 @@ namespace AssetImportScanner
/**
* Package path implied by a file's position under the import root.
*
* Correct for a MirrorTree export, which writes Game/Art/SM_Rock.uasset, and a guess
* Correct for a MirrorTree export, which writes Game/Space/Art/SM_Rock.uasset, and a guess
* for anything else. Returns None when the result would not be a valid package name.
*/
FName PackageFromFolderStructure(const FString& RelativePath)
@@ -146,7 +146,7 @@ namespace AssetImporter
continue;
}
// Perforce-primary projects keep unopened files read-only, so an existing target is very likely read-only.
// This project is Perforce-primary, so an existing target is very likely read-only.
// Without clearing the flag the copy fails with an error that reads like a permissions
// problem rather than "the file is not checked out".
if (bTargetOnDisk && FileManager.IsReadOnly(*DestinationPath))
@@ -79,9 +79,9 @@ TArray<FTopLevelAssetPath> GetPresetClassPaths(EAssetTypePreset Preset)
};
case EAssetTypePreset::Sound:
// Audio middleware (FMOD) is a representative case: its assets are included for
// completeness, but it resolves events by string path outside the UObject graph, so
// the usage verdict is unreliable by construction - the report marks them Unknown.
// FMOD is this project's audio system. FMOD assets are included for completeness, but
// FMOD resolves events by string path outside the UObject graph, so their usage verdict
// is unreliable by construction - the report marks them Unknown.
return {
Path(TEXT("/Script/Engine.SoundBase")),
Path(TEXT("/Script/FMODStudio.FMODAsset"))
@@ -536,7 +536,7 @@ FAssetUsageExporter::FResult FAssetUsageExporter::CopyExpandedPackages(const TAr
const bool bWasPresent = FileManager.FileExists(*DestinationPath);
// Perforce-primary repositories keep unopened files read-only, so the source .uasset is very often read-only on
// This repository is Perforce-primary, so the source .uasset is very often read-only on
// disk. Windows CopyFile carries that attribute across, which would make the export folder
// read-only too - and then an Overwrite-policy re-export fails on its own previous output.
// Clear the flag on both ends of the problem: before writing over an existing file, and
@@ -243,8 +243,8 @@ void FLevelUsageResolver::TraverseLevel(int32 LevelIndex, const FLevelUsageResol
}
// Reaching another World is NOT automatically a sublevel. An earlier version assumed it
// was, and on WP_Example that assumption pulled the whole of L_Other in through
// BP_GameMode -> PDA_MenuConfig: 9994 of 18136 rows belonged to a
// was, and on WP_Main that assumption pulled the whole of L_MainLevel in through
// BP_FirstPersonGameMode -> PDA_MenuSystemConfig: 9994 of 18136 rows belonged to a
// different map. A crossing only counts when the reference comes from the level package
// itself or from one of its external actor packages, which is the shape a streaming
// sublevel or a Level Instance actually has.
@@ -1,151 +0,0 @@
// MagentaDolphin 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "UObject/TopLevelAssetPath.h"
class IAssetRegistry;
/**
* What would happen if this set of packages were deleted - worked out before anything is touched.
*
* Deletion is the one operation in this tool that destroys work, and the audit's own verdicts are
* not a safe basis for it on their own: Unknown means "the registry cannot see it", not "unused",
* and the whole point of the five-state verdict is that the tool never presents a guess as a fact.
* So the scan answers a narrower, checkable question - who still points at this, right now, in the
* registry - and leaves the judgement to the person reading it.
*
* Pure registry and filesystem work. It loads nothing, deletes nothing, and is deliberately
* separate from the code that performs the delete: the analysis has to be testable without an
* editor, and the actual removal is ObjectTools' job in the Editor module, not ours.
*/
namespace AssetDeletionScan
{
/**
* Why a ticked package will not be deleted.
*
* Refusals are policy, not failures, and each one is shown with its reason. Silently dropping a
* row from the delete set would be worse than refusing loudly - the user ticked it and is owed
* an explanation.
*/
enum class ERefusal : uint8
{
/** No objection. */
None,
/**
* The package is a map.
*
* Refused by decision, not by capability. A level is the unit the whole tool measures usage
* against, and deleting one silently invalidates every other row in the result. The engine
* also refuses to delete a level that is currently open, which would make the outcome depend
* on which map the user happens to have loaded.
*/
IsLevel,
/**
* A One File Per Actor package - an actor or object belonging to some level.
*
* Deleting one is not deleting an asset, it is deleting a placed actor out of a map behind
* the level editor's back. That belongs in the level editor, with its undo.
*/
IsExternalPackage,
/** /Engine, /Script or /Temp. Not this project's to delete. */
NotProjectContent,
/** The registry has no asset under this package name; nothing to delete. */
Missing
};
ASSETUSAGEAUDITCORE_API const TCHAR* LexToString(ERefusal Refusal);
/** One line of explanation, suitable for showing beside the row. */
ASSETUSAGEAUDITCORE_API FText DescribeRefusal(ERefusal Refusal);
/** One ticked package and what stands in the way of deleting it. */
struct FCandidate
{
FName PackageName;
FName AssetName;
FTopLevelAssetPath ClassPath;
/** Content/... form, as the report shows it. */
FString PathFromProjectRoot;
ERefusal Refusal = ERefusal::None;
/**
* Packages that reference this one and are NOT themselves being deleted.
*
* The exclusion matters: deleting a Blueprint together with the mesh only it uses is a
* clean operation, and listing the Blueprint as a blocker would make every sensible
* multi-asset delete look dangerous. Truncated to FOptions::MaxReferencersListed - the list
* is there to be read, and forty names is not read.
*/
TArray<FName> OutsideReferencers;
/** Full count before truncation. */
int32 OutsideReferencerCount = 0;
/** How many of those referencers are maps or OFPA packages - i.e. it is placed in a level. */
int32 LevelReferencerCount = 0;
/**
* The file exists on disk and is marked read-only.
*
* Worth surfacing here rather than discovering it as a failure afterwards: this project is
* Perforce-primary and unopened files are read-only by default, so a delete attempted
* without checking out first fails per-file, halfway through.
*/
bool bReadOnlyOnDisk = false;
bool CanDelete() const
{
return Refusal == ERefusal::None;
}
};
struct FOptions
{
/** How many referencer names to keep per candidate. The count is always exact. */
int32 MaxReferencersListed = 8;
};
// Exported: Summarise is defined out of line, so the Editor module cannot link without this.
struct ASSETUSAGEAUDITCORE_API FStats
{
int32 Requested = 0;
int32 Deletable = 0;
int32 RefusedLevels = 0;
int32 RefusedExternal = 0;
int32 RefusedNotProjectContent = 0;
int32 RefusedMissing = 0;
/** Deletable candidates that something outside the set still points at. */
int32 WithOutsideReferencers = 0;
/** Deletable candidates whose file is read-only on disk. */
int32 ReadOnly = 0;
FString Summarise() const;
};
/**
* Classify the packages and collect their outside referencers.
*
* Order is preserved, refusals included, so the caller can show the whole ticked set with a
* reason against each row instead of a shorter list that quietly lost entries.
*
* Referencers are queried with AssetUsageAudit::MakeTraversalQuery() for the same reason the
* audit uses it: a Hard-only query misses every soft reference, and a soft reference still
* breaks when its target disappears - it just breaks at runtime instead of at load.
*/
ASSETUSAGEAUDITCORE_API TArray<FCandidate> Scan(
IAssetRegistry& Registry,
const TArray<FName>& Packages,
const FOptions& Options,
FStats& OutStats);
}
@@ -73,8 +73,8 @@ namespace AssetDependencyClosure
* 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_Example
* the chain BP_GameMode -> PDA_MenuConfig -> L_Other drags in 9994
* 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.
*
@@ -139,7 +139,7 @@ enum class EExportCollisionPolicy : uint8
*/
struct ASSETUSAGEAUDITCORE_API FAssetUsageRow
{
/** Package name, e.g. /Game/Art/SM_Rock. */
/** Package name, e.g. /Game/Space/Art/SM_Rock. */
FName PackageName;
/** Asset name without path. */
@@ -154,7 +154,7 @@ struct ASSETUSAGEAUDITCORE_API FAssetUsageRow
*/
FTopLevelAssetPath GeneratedClassPath;
/** Path relative to the project root, e.g. Content/Art/SM_Rock.uasset. */
/** Path relative to the project root, e.g. Content/Space/Art/SM_Rock.uasset. */
FString PathFromProjectRoot;
EAssetUsageVerdict Verdict = EAssetUsageVerdict::Unknown;
@@ -172,7 +172,7 @@ struct ASSETUSAGEAUDITCORE_API FAssetUsageRow
/**
* Human-readable chain explaining the verdict, e.g.
* "WP_Example -> __ExternalActors__/.../A2B -> BP_Rock -> SM_Rock".
* "WP_Main -> __ExternalActors__/.../A2B -> BP_Rock -> SM_Rock".
* A verdict without a route is unactionable for an artist.
*/
FString Route;
@@ -51,7 +51,7 @@ namespace AssetUsagePaths
/**
* Package name to a path relative to the project root, as the report requires.
* "/Game/Art/SM_Rock" -> "Content/Art/SM_Rock.uasset"
* "/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.
@@ -9,7 +9,7 @@
*/
struct FIndirectReference
{
/** Package name the literal resolved to, e.g. /Game/Core/GameModes/BP_GameMode. */
/** 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. */
@@ -28,7 +28,7 @@ struct FIndirectReference
* 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_GameMode, BP_MenuGameInstance, RefinedMenuMap and Gyms_Geoda as plain
* 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.
*
@@ -37,8 +37,8 @@ struct FLevelUsageResolveOptions
*
* 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_Example -> BP_GameMode -> PDA_MenuConfig -> L_Other drags in the whole
* of L_Other, which measured at 9994 of WP_Example's 18136 rows - two thirds of the answer
* 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
@@ -72,7 +72,7 @@ struct FLevelUsageStats
* 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_Example it was two thirds of the report.
* been attributed to this one. Worth surfacing: on WP_Main it was two thirds of the report.
*/
int32 ForeignLevelsNotExpanded = 0;
double ScanLevelAssetsSeconds = 0.0;
@@ -11,8 +11,8 @@ UAssetUsageAuditSettings::UAssetUsageAuditSettings()
// third-party, so leaving these in makes the first run unreadable for an artist looking for
// their own content.
//
// Deliberately NOT excluded, despite being a bought pack: Content/a purchased UI pack. It supplies
// BP_MenuGameInstance, which DefaultEngine.ini names as the project's GameInstance.
// Deliberately NOT excluded, despite being a bought pack: Content/MenuSystemPro. It supplies
// BP_MenuSystemGameInstance, which DefaultEngine.ini names as the project's GameInstance.
// Excluding it would hide a load-bearing asset from every report.
ExcludedPackagePaths = {
TEXT("Content/3rdParty"),
@@ -1,669 +0,0 @@
// MagentaDolphin 2026. Asset Usage Audit.
#include "SAssetDeleteDialog.h"
#include "AssetDeletionScan.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "AssetUsageAuditEditorModule.h"
#include "Framework/Application/SlateApplication.h"
#include "Framework/Docking/TabManager.h"
#include "ObjectTools.h"
#include "Styling/AppStyle.h"
#include "Styling/CoreStyle.h"
#include "Widgets/Input/SButton.h"
#include "Widgets/Input/SCheckBox.h"
#include "Widgets/Layout/SBorder.h"
#include "Widgets/Layout/SBox.h"
#include "Widgets/Layout/SSeparator.h"
#include "Widgets/SBoxPanel.h"
#include "Widgets/SWindow.h"
#include "Widgets/Text/STextBlock.h"
#include "Widgets/Views/SHeaderRow.h"
#include "Widgets/Views/SListView.h"
#define LOCTEXT_NAMESPACE "AssetDeleteDialog"
namespace DeleteColumns
{
static const FName Check(TEXT("Check"));
static const FName Name(TEXT("Name"));
static const FName Type(TEXT("Type"));
static const FName Path(TEXT("Path"));
static const FName Referencers(TEXT("Referencers"));
static const FName Status(TEXT("Status"));
}
namespace
{
/** Red enough to read as a warning, light enough to stay legible on the dark editor theme. */
const FLinearColor DangerColor(0.95f, 0.35f, 0.30f);
const FLinearColor CautionColor(0.95f, 0.72f, 0.25f);
const FLinearColor MutedColor(0.6f, 0.6f, 0.6f);
}
/** One candidate plus its tick state. */
struct FDeleteRow
{
AssetDeletionScan::FCandidate Candidate;
/**
* Ticked by default when nothing objects.
*
* The user already ticked these rows in the panel; re-ticking them here would be busywork.
* What the window adds is the chance to *un*tick, having now been shown the referencers.
*/
bool bChecked = false;
};
using FDeleteRowPtr = TSharedPtr<FDeleteRow>;
class SDeleteRowWidget : public SMultiColumnTableRow<FDeleteRowPtr>
{
public:
SLATE_BEGIN_ARGS(SDeleteRowWidget) {}
SLATE_ARGUMENT(FDeleteRowPtr, Row)
SLATE_EVENT(FSimpleDelegate, OnCheckChanged)
SLATE_END_ARGS()
void Construct(const FArguments& InArgs, const TSharedRef<STableViewBase>& InOwnerTable)
{
Row = InArgs._Row;
OnCheckChanged = InArgs._OnCheckChanged;
SMultiColumnTableRow<FDeleteRowPtr>::Construct(FSuperRowType::FArguments(), InOwnerTable);
}
virtual TSharedRef<SWidget> GenerateWidgetForColumn(const FName& ColumnName) override
{
if (!Row.IsValid())
{
return SNullWidget::NullWidget;
}
const AssetDeletionScan::FCandidate& Candidate = Row->Candidate;
const bool bCanDelete = Candidate.CanDelete();
if (ColumnName == DeleteColumns::Check)
{
return SNew(SBox)
.HAlign(HAlign_Center)
.VAlign(VAlign_Center)
[
SNew(SCheckBox)
.IsEnabled(bCanDelete)
.IsChecked_Lambda([this]()
{
return Row->bChecked ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
})
.OnCheckStateChanged_Lambda([this](ECheckBoxState NewState)
{
Row->bChecked = (NewState == ECheckBoxState::Checked);
OnCheckChanged.ExecuteIfBound();
})
];
}
if (ColumnName == DeleteColumns::Name)
{
return SNew(STextBlock)
.Text(FText::FromName(Candidate.AssetName))
.ColorAndOpacity(bCanDelete ? FSlateColor::UseForeground() : FSlateColor(MutedColor))
.ToolTipText(FText::FromName(Candidate.PackageName));
}
if (ColumnName == DeleteColumns::Type)
{
return SNew(STextBlock)
.Text(FText::FromString(Candidate.ClassPath.GetAssetName().ToString()))
.ColorAndOpacity(FSlateColor(MutedColor));
}
if (ColumnName == DeleteColumns::Path)
{
return SNew(STextBlock)
.Text(FText::FromString(Candidate.PathFromProjectRoot))
.ColorAndOpacity(FSlateColor(MutedColor))
.ToolTipText(FText::FromString(Candidate.PathFromProjectRoot));
}
if (ColumnName == DeleteColumns::Referencers)
{
if (Candidate.OutsideReferencerCount == 0)
{
return SNew(STextBlock)
.Text(LOCTEXT("NoReferencers", "none"))
.ColorAndOpacity(FSlateColor(MutedColor))
.ToolTipText(LOCTEXT("NoReferencersTip", "Nothing outside this delete set points at it, as far as the Asset Registry can see. Config files, C++ string paths and FMOD events are outside what it can see."));
}
// The names matter more than the number: "3" tells you to stop, the names tell you
// whether stopping was right.
FString Tooltip;
for (FName Referencer : Candidate.OutsideReferencers)
{
Tooltip += Referencer.ToString() + TEXT("\n");
}
if (Candidate.OutsideReferencerCount > Candidate.OutsideReferencers.Num())
{
Tooltip += FString::Printf(TEXT("... and %d more"),
Candidate.OutsideReferencerCount - Candidate.OutsideReferencers.Num());
}
const FText Label = Candidate.LevelReferencerCount > 0
? FText::Format(
LOCTEXT("ReferencersWithLevels", "{0} ({1} on levels)"),
FText::AsNumber(Candidate.OutsideReferencerCount),
FText::AsNumber(Candidate.LevelReferencerCount))
: FText::AsNumber(Candidate.OutsideReferencerCount);
return SNew(STextBlock)
.Text(Label)
.ColorAndOpacity(FSlateColor(CautionColor))
.ToolTipText(FText::FromString(Tooltip));
}
if (ColumnName == DeleteColumns::Status)
{
if (!bCanDelete)
{
return SNew(STextBlock)
.Text(AssetDeletionScan::DescribeRefusal(Candidate.Refusal))
.ColorAndOpacity(FSlateColor(MutedColor));
}
if (Candidate.bReadOnlyOnDisk)
{
return SNew(STextBlock)
.Text(LOCTEXT("ReadOnly", "read-only on disk"))
.ColorAndOpacity(FSlateColor(CautionColor))
.ToolTipText(LOCTEXT("ReadOnlyTip", "The file is not writable. Perforce-primary projects keep unopened files read-only, so an unopened file is read-only and the delete will fail on it. Check it out first."));
}
return SNew(STextBlock)
.Text(LOCTEXT("WillDelete", "will be deleted"))
.ColorAndOpacity(FSlateColor(DangerColor));
}
return SNullWidget::NullWidget;
}
private:
FDeleteRowPtr Row;
FSimpleDelegate OnCheckChanged;
};
/**
* The window body.
*
* Owns the scan result and the tick state; deletes nothing until Delete is pressed, and reports
* back through the outcome struct rather than mutating anything the panel owns.
*/
class SAssetDeleteDialog : public SCompoundWidget
{
public:
SLATE_BEGIN_ARGS(SAssetDeleteDialog) {}
SLATE_ARGUMENT(TArray<FName>, Packages)
SLATE_ARGUMENT(TSharedPtr<SWindow>, ParentWindow)
SLATE_END_ARGS()
void Construct(const FArguments& InArgs)
{
ParentWindow = InArgs._ParentWindow;
FAssetRegistryModule& RegistryModule =
FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry"));
IAssetRegistry& Registry = RegistryModule.Get();
AssetDeletionScan::FOptions ScanOptions;
const TArray<AssetDeletionScan::FCandidate> Candidates =
AssetDeletionScan::Scan(Registry, InArgs._Packages, ScanOptions, Stats);
for (const AssetDeletionScan::FCandidate& Candidate : Candidates)
{
FDeleteRowPtr NewRow = MakeShared<FDeleteRow>();
NewRow->Candidate = Candidate;
NewRow->bChecked = Candidate.CanDelete();
Rows.Add(NewRow);
}
UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Delete scan: %s"), *Stats.Summarise());
BuildHeaderRow();
ChildSlot
[
SNew(SBorder)
.BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder"))
.Padding(12.0f)
[
SNew(SVerticalBox)
// --- The headline ------------------------------------------------------------
//
// Stated as a consequence rather than as a question. "Delete 12 assets?" invites a
// reflex click; naming what disappears does not.
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 6.0f)
[
SNew(STextBlock)
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 13))
.ColorAndOpacity(FSlateColor(DangerColor))
.Text(FText::Format(
LOCTEXT("Headline", "{0} asset file(s) will be deleted from this project."),
FText::AsNumber(Stats.Deletable)))
]
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 8.0f)
[
SNew(STextBlock)
.AutoWrapText(true)
.Text(LOCTEXT("Explanation",
"This removes the files from disk. It is not an export and there is no undo - what protects you is source control, so make sure this content is submitted or checked out before continuing.\n\n"
"Bear in mind what the audit cannot see: assets named from C++ or Blueprint strings, rows inside DataTables, and FMOD events are invisible to the Asset Registry. An asset with no referencers here is not proof that nothing uses it."))
]
// --- Reference warning -------------------------------------------------------
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 8.0f)
[
SNew(SBorder)
.BorderImage(FAppStyle::GetBrush("ToolPanel.DarkGroupBorder"))
.Padding(8.0f)
.Visibility_Lambda([this]()
{
return Stats.WithOutsideReferencers > 0 ? EVisibility::Visible : EVisibility::Collapsed;
})
[
SNew(STextBlock)
.AutoWrapText(true)
.ColorAndOpacity(FSlateColor(CautionColor))
.Text(FText::Format(
LOCTEXT("ReferencerWarning",
"{0} of them are still referenced by something outside this selection. Deleting those leaves the referencing assets pointing at nothing.\n"
"Unreal's own Delete Assets window opens next and lists every reference, where you can replace them with another asset or force the delete."),
FText::AsNumber(Stats.WithOutsideReferencers)))
]
]
// --- Refusals ----------------------------------------------------------------
//
// Shown, not hidden. The user ticked those rows; a delete that quietly acts on
// fewer assets than were ticked is how trust in a tool is lost.
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 8.0f)
[
SNew(STextBlock)
.AutoWrapText(true)
.ColorAndOpacity(FSlateColor(MutedColor))
.Visibility_Lambda([this]()
{
return (Stats.Requested - Stats.Deletable) > 0 ? EVisibility::Visible : EVisibility::Collapsed;
})
.Text_Lambda([this]() { return GetRefusalText(); })
]
+ SVerticalBox::Slot()
.FillHeight(1.0f)
[
SNew(SBox)
.MinDesiredHeight(240.0f)
[
SAssignNew(ListView, SListView<FDeleteRowPtr>)
.ListItemsSource(&Rows)
.SelectionMode(ESelectionMode::None)
.HeaderRow(HeaderRow)
.OnGenerateRow_Lambda([this](FDeleteRowPtr Item, const TSharedRef<STableViewBase>& OwnerTable)
{
return SNew(SDeleteRowWidget, OwnerTable)
.Row(Item)
.OnCheckChanged(FSimpleDelegate::CreateLambda([this]() { Invalidate(EInvalidateWidgetReason::Paint); }));
})
]
]
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 8.0f)
[
SNew(SSeparator)
]
// --- Acknowledgement ---------------------------------------------------------
//
// A deliberate second action. The Delete button stays disabled until this is
// ticked, which costs one click and removes the "I meant to press Cancel" class
// of accident entirely.
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 8.0f)
[
SNew(SCheckBox)
.IsChecked_Lambda([this]() { return bAcknowledged ? ECheckBoxState::Checked : ECheckBoxState::Unchecked; })
.OnCheckStateChanged_Lambda([this](ECheckBoxState NewState) { bAcknowledged = (NewState == ECheckBoxState::Checked); })
[
SNew(STextBlock)
.Margin(FMargin(4.0f, 0.0f, 0.0f, 0.0f))
.Text(LOCTEXT("Acknowledge", "I understand these files will be removed from the project."))
]
]
+ SVerticalBox::Slot()
.AutoHeight()
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.VAlign(VAlign_Center)
[
SNew(STextBlock)
.ColorAndOpacity(FSlateColor(MutedColor))
.Text_Lambda([this]()
{
return FText::Format(
LOCTEXT("Footer", "{0} ticked of {1} that can be deleted."),
FText::AsNumber(CountChecked()),
FText::AsNumber(Stats.Deletable));
})
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f)
[
SNew(SButton)
.Text(LOCTEXT("Cancel", "Cancel"))
.OnClicked(this, &SAssetDeleteDialog::OnCancel)
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f)
[
SNew(SButton)
.Text_Lambda([this]()
{
return FText::Format(
LOCTEXT("DeleteButton", "Delete {0} asset(s)"),
FText::AsNumber(CountChecked()));
})
.ToolTipText(LOCTEXT("DeleteButtonTip", "Hand the ticked assets to Unreal's delete, which will show its own reference window before anything is removed."))
.IsEnabled_Lambda([this]() { return bAcknowledged && CountChecked() > 0; })
.OnClicked(this, &SAssetDeleteDialog::OnDelete)
]
]
]
];
}
bool WasAccepted() const
{
return bAccepted;
}
/** Packages the user confirmed, in the order they were listed. */
TArray<FName> GetConfirmedPackages() const
{
TArray<FName> Result;
for (const FDeleteRowPtr& Row : Rows)
{
if (Row->bChecked && Row->Candidate.CanDelete())
{
Result.Add(Row->Candidate.PackageName);
}
}
return Result;
}
const AssetDeletionScan::FStats& GetStats() const
{
return Stats;
}
private:
void BuildHeaderRow()
{
HeaderRow = SNew(SHeaderRow)
+ SHeaderRow::Column(DeleteColumns::Check)
.DefaultLabel(FText::GetEmpty())
.FixedWidth(28.0f)
[
SNew(SBox)
.HAlign(HAlign_Center)
.VAlign(VAlign_Center)
[
SNew(SCheckBox)
.IsChecked_Lambda([this]() { return GetHeaderCheckState(); })
.OnCheckStateChanged_Lambda([this](ECheckBoxState NewState) { SetAllChecked(NewState == ECheckBoxState::Checked); })
]
]
+ SHeaderRow::Column(DeleteColumns::Name)
.DefaultLabel(LOCTEXT("ColName", "Asset"))
.FillWidth(0.24f)
+ SHeaderRow::Column(DeleteColumns::Type)
.DefaultLabel(LOCTEXT("ColType", "Type"))
.FillWidth(0.13f)
+ SHeaderRow::Column(DeleteColumns::Path)
.DefaultLabel(LOCTEXT("ColPath", "Path"))
.FillWidth(0.33f)
+ SHeaderRow::Column(DeleteColumns::Referencers)
.DefaultLabel(LOCTEXT("ColReferencers", "Referenced by"))
.FillWidth(0.15f)
+ SHeaderRow::Column(DeleteColumns::Status)
.DefaultLabel(LOCTEXT("ColStatus", "Status"))
.FillWidth(0.15f);
}
ECheckBoxState GetHeaderCheckState() const
{
const int32 Checked = CountChecked();
if (Checked == 0)
{
return ECheckBoxState::Unchecked;
}
return Checked == Stats.Deletable ? ECheckBoxState::Checked : ECheckBoxState::Undetermined;
}
void SetAllChecked(bool bNewChecked)
{
for (const FDeleteRowPtr& Row : Rows)
{
if (Row->Candidate.CanDelete())
{
Row->bChecked = bNewChecked;
}
}
if (ListView.IsValid())
{
ListView->RequestListRefresh();
}
}
int32 CountChecked() const
{
int32 Count = 0;
for (const FDeleteRowPtr& Row : Rows)
{
if (Row->bChecked && Row->Candidate.CanDelete())
{
++Count;
}
}
return Count;
}
FText GetRefusalText() const
{
TArray<FString> Parts;
if (Stats.RefusedLevels > 0)
{
Parts.Add(FString::Printf(TEXT("%d level(s)"), Stats.RefusedLevels));
}
if (Stats.RefusedExternal > 0)
{
Parts.Add(FString::Printf(TEXT("%d actor package(s)"), Stats.RefusedExternal));
}
if (Stats.RefusedNotProjectContent > 0)
{
Parts.Add(FString::Printf(TEXT("%d outside project content"), Stats.RefusedNotProjectContent));
}
if (Stats.RefusedMissing > 0)
{
Parts.Add(FString::Printf(TEXT("%d already gone"), Stats.RefusedMissing));
}
return FText::Format(
LOCTEXT("RefusalSummary", "Kept out of the delete: {0}. They are listed below with a reason, and cannot be ticked."),
FText::FromString(FString::Join(Parts, TEXT(", "))));
}
FReply OnDelete()
{
bAccepted = true;
CloseWindow();
return FReply::Handled();
}
FReply OnCancel()
{
bAccepted = false;
CloseWindow();
return FReply::Handled();
}
void CloseWindow()
{
if (ParentWindow.IsValid())
{
ParentWindow->RequestDestroyWindow();
}
}
TArray<FDeleteRowPtr> Rows;
AssetDeletionScan::FStats Stats;
bool bAcknowledged = false;
bool bAccepted = false;
TSharedPtr<SWindow> ParentWindow;
TSharedPtr<SListView<FDeleteRowPtr>> ListView;
TSharedPtr<SHeaderRow> HeaderRow;
};
FAssetDeleteOutcome ShowAssetDeleteDialog(const TArray<FName>& Packages)
{
FAssetDeleteOutcome Outcome;
Outcome.Requested = Packages.Num();
FAssetRegistryModule& RegistryModule =
FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry"));
IAssetRegistry& Registry = RegistryModule.Get();
// The engine refuses to delete while the registry is still discovering, and does so with a
// transient notification that is easy to miss. Better to wait here than to have the delete
// quietly do nothing.
if (Registry.IsLoadingAssets())
{
Registry.WaitForCompletion();
}
TSharedRef<SWindow> Window = SNew(SWindow)
.Title(LOCTEXT("WindowTitle", "Delete ticked assets"))
.ClientSize(FVector2D(940.0f, 620.0f))
.SupportsMinimize(false)
.SupportsMaximize(false);
TSharedRef<SAssetDeleteDialog> Dialog = SNew(SAssetDeleteDialog)
.Packages(Packages)
.ParentWindow(Window);
Window->SetContent(Dialog);
FSlateApplication::Get().AddModalWindow(Window, FGlobalTabmanager::Get()->GetRootWindow());
Outcome.Refused = Dialog->GetStats().Requested - Dialog->GetStats().Deletable;
if (!Dialog->WasAccepted())
{
UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Delete cancelled; nothing was touched."));
return Outcome;
}
const TArray<FName> Confirmed = Dialog->GetConfirmedPackages();
Outcome.bCancelled = false;
Outcome.Attempted = Confirmed.Num();
if (Confirmed.Num() == 0)
{
return Outcome;
}
// Resolve to FAssetData at the last moment. The scan ran before the window opened and the
// user may have spent a while reading it; a stale FAssetData would ask the engine to delete
// something that has already moved.
TArray<FAssetData> ToDelete;
for (FName Package : Confirmed)
{
TArray<FAssetData> Assets;
Registry.GetAssetsByPackageName(Package, Assets, /*bIncludeOnlyOnDiskAssets=*/true);
ToDelete.Append(Assets);
}
if (ToDelete.Num() == 0)
{
UE_LOG(LogAssetUsageAuditEditor, Warning,
TEXT("Nothing was deleted: none of the %d confirmed packages still hold an asset."), Confirmed.Num());
return Outcome;
}
// The engine's own delete, with its confirmation left on. That window is where referencers are
// listed and where Force Delete and Replace References live - the behaviour the request asked
// to match, obtained by using it rather than by imitating it.
const int32 ObjectsDeleted = ObjectTools::DeleteAssets(ToDelete, /*bShowConfirmation=*/true);
// Count packages, not objects: the engine returns deleted UObjects, and asking the registry
// what actually disappeared is the only figure that matches the Content Browser.
for (FName Package : Confirmed)
{
TArray<FAssetData> Remaining;
Registry.GetAssetsByPackageName(Package, Remaining, /*bIncludeOnlyOnDiskAssets=*/true);
if (Remaining.Num() == 0)
{
Outcome.DeletedPackages.Add(Package);
}
}
UE_LOG(LogAssetUsageAuditEditor, Display,
TEXT("Delete finished: %d package(s) gone of %d confirmed (%d object(s) reported by the engine)."),
Outcome.DeletedPackages.Num(), Confirmed.Num(), ObjectsDeleted);
return Outcome;
}
#undef LOCTEXT_NAMESPACE
@@ -1,46 +0,0 @@
// MagentaDolphin 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
/** What the delete window ended up doing. Everything the panel needs to report afterwards. */
struct FAssetDeleteOutcome
{
/** Packages handed to the window. */
int32 Requested = 0;
/** Refused by policy - levels, OFPA packages, engine content. Never reaches the engine. */
int32 Refused = 0;
/** Ticked and passed to ObjectTools. */
int32 Attempted = 0;
/**
* Packages the registry can no longer find afterwards.
*
* Measured rather than taken from the engine's return value: ObjectTools reports a count of
* deleted *objects*, and one package can hold several. Asking the registry what is actually
* gone is the only figure that matches what the user will see in the Content Browser.
*/
TArray<FName> DeletedPackages;
/** True when the user closed or cancelled the window; nothing was touched. */
bool bCancelled = true;
};
/**
* Show the delete window: what is about to be destroyed, what still points at it, and why some
* rows are refused.
*
* Modal, and deliberately heavier than the export window. Export is reversible by deleting the
* output folder; this is not, and the window's job is to be read rather than clicked through -
* hence the per-row referencer count, the explicit acknowledgement, and Cancel as the default.
*
* The window does not delete anything itself. Once confirmed it hands the assets to
* ObjectTools::DeleteAssets with the engine's own confirmation enabled, so the familiar Delete
* Assets dialog - referencer list, Force Delete, Replace References - appears exactly as it does
* from the Content Browser. Reimplementing that would mean reimplementing reference replacement,
* and getting it subtly wrong on someone's project.
*/
FAssetDeleteOutcome ShowAssetDeleteDialog(const TArray<FName>& Packages);
@@ -12,7 +12,6 @@
#include "AssetUsageExporter.h"
#include "AssetUsagePaths.h"
#include "AssetUsageReportWriter.h"
#include "SAssetDeleteDialog.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Algo/AllOf.h"
@@ -375,7 +374,7 @@ void SAssetUsageAuditPanel::Construct(const FArguments& InArgs)
}
// Name the level when there is one, count them when there are several:
// a button reading "WP_Example" is worth more than one reading "1 level".
// a button reading "WP_Main" is worth more than one reading "1 level".
if (LevelFilters.Num() == 1)
{
return FText::FromString(FPackageName::GetShortName(*LevelFilters.CreateConstIterator()));
@@ -581,33 +580,6 @@ void SAssetUsageAuditPanel::Construct(const FArguments& InArgs)
.OnClicked(this, &SAssetUsageAuditPanel::OnExportFilesClicked)
.IsEnabled(this, &SAssetUsageAuditPanel::HasCheckedAssets)
]
// Delete sits last and behind a separator, deliberately. It acts on the same ticked
// set as Export, so it belongs on this bar, but it is the only irreversible action in
// the tool and must never be the button someone reaches by muscle memory.
+ SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
.Padding(8.0f, 2.0f, 2.0f, 2.0f)
[
SNew(SSeparator)
.Orientation(Orient_Vertical)
]
+ SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
.Padding(2.0f, 0.0f)
[
SNew(SButton)
.Text(LOCTEXT("DeleteTicked", "Delete ticked assets..."))
.ToolTipText(LOCTEXT("DeleteTickedTip",
"Permanently delete the ticked assets from the project. Levels are never deleted. "
"Shows what still references them before anything is removed."))
.ButtonColorAndOpacity(FLinearColor(0.55f, 0.16f, 0.16f))
.OnClicked(this, &SAssetUsageAuditPanel::OnDeleteClicked)
.IsEnabled(this, &SAssetUsageAuditPanel::HasCheckedAssets)
]
]
];
}
@@ -2252,83 +2224,6 @@ FReply SAssetUsageAuditPanel::OnExportFilesClicked()
return FReply::Handled();
}
// --- Deleting ----------------------------------------------------------------------------------
FReply SAssetUsageAuditPanel::OnDeleteClicked()
{
TArray<FAssetUsageRow> Rows;
GetCheckedRows(Rows);
if (Rows.IsEmpty())
{
return FReply::Handled();
}
TArray<FName> Packages;
Packages.Reserve(Rows.Num());
for (const FAssetUsageRow& Row : Rows)
{
Packages.Add(Row.PackageName);
}
// Deliberately NOT widened with the dependency closure, unlike export. Export widens so that a
// mesh arrives with its materials; widening a delete the same way would destroy content nobody
// ticked, and a single shared texture would drag half the project along with it. Delete acts on
// exactly what was ticked, and never on more.
const FAssetDeleteOutcome Outcome = ShowAssetDeleteDialog(Packages);
if (Outcome.bCancelled)
{
StatusText = LOCTEXT("DeleteCancelled", "Delete cancelled - nothing was changed.");
return FReply::Handled();
}
ForgetDeletedPackages(Outcome.DeletedPackages);
StatusText = FText::Format(
LOCTEXT("DeleteFinished",
"Deleted {0} of {1} ticked asset(s); {2} were refused. Reference counts elsewhere in this result are now out of date - run the audit again."),
FText::AsNumber(Outcome.DeletedPackages.Num()),
FText::AsNumber(Outcome.Requested),
FText::AsNumber(Outcome.Refused));
return FReply::Handled();
}
void SAssetUsageAuditPanel::ForgetDeletedPackages(const TArray<FName>& DeletedPackages)
{
if (DeletedPackages.IsEmpty())
{
return;
}
const TSet<FName> Gone(DeletedPackages);
LastResult.Rows.RemoveAll([&Gone](const FAssetUsageRow& Row)
{
return Gone.Contains(Row.PackageName);
});
AllRows.RemoveAll([&Gone](const FAssetUsageRowPtr& Row)
{
return Row.IsValid() && Gone.Contains(Row->PackageName);
});
for (FName Package : DeletedPackages)
{
CheckedPackages.Remove(Package);
}
// Removing the rows is honest but incomplete: everything that referenced them still carries the
// reference counts it had before, and an asset that was UsedByAssetsOnly through a now-deleted
// Blueprint has a different verdict than the one on screen. Marking the result stale says so,
// rather than leaving figures that look freshly measured.
bResultStale = true;
RebuildFilteredRows();
}
void SAssetUsageAuditPanel::RunMigrateExport(const TArray<FAssetUsageRow>& Rows, const FAssetExportRequest& Request)
{
TArray<FName> Packages;
@@ -278,31 +278,6 @@ private:
FReply OnExportFilesClicked();
bool HasCheckedAssets() const;
// --- Deleting ----------------------------------------------------------------------------
/**
* Delete the ticked assets, after two confirmations.
*
* Sits beside Export because it acts on the same ticked set, and is the reason the audit is
* worth running: the point of finding unused content is to be able to remove it. It is kept
* visually apart and never becomes the default action.
*
* Levels are refused outright - see AssetDeletionScan::ERefusal::IsLevel. Everything else the
* window explains before anything is touched, and the destruction itself is ObjectTools' code,
* not ours.
*/
FReply OnDeleteClicked();
/**
* Drop deleted packages from the result without re-running the sweep.
*
* A full re-run after a delete would cost minutes on this project, and the rows that remain are
* still correct - what changed is that some assets are gone, plus the referencer counts of
* whatever pointed at them. The result is marked stale so the numbers are not mistaken for a
* fresh sweep.
*/
void ForgetDeletedPackages(const TArray<FName>& DeletedPackages);
/** Byte copy of the package files, expanding levels to their OFPA packages. */
void RunCopyExport(const TArray<FAssetUsageRow>& Rows, const FAssetExportRequest& Request);
@@ -25,7 +25,7 @@ public:
/**
* Run a full audit and write JSON + CSV reports.
*
* @param LevelPackageNames Levels to analyse, e.g. "/Game/Maps/WP_Example". Empty means all.
* @param LevelPackageNames Levels to analyse, e.g. "/Game/Space/Maps/WP_Main". Empty means all.
* @param OutputDirectory Destination. Empty uses the configured default.
* @param OutReportPath Absolute path of the JSON report on success.
* @return true when both reports were written.
@@ -10,7 +10,7 @@
/**
* Team-shared configuration, written to Config/DefaultEditor.ini.
*
* That file is normally under version control, so the exclusion list and the
* That file is Perforce-tracked (only Plugins/** is p4-ignored), so the exclusion list and the
* type presets are reviewable and shared. Per-user state - last filter, column layout - belongs
* in UAssetUsageAuditUserSettings instead, which stays in Saved/Config.
*/
@@ -1,449 +0,0 @@
// MagentaDolphin 2026. Asset Usage Audit.
#include "AssetDeletionScan.h"
#include "AssetRegistry/ARFilter.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "AssetUsageAuditTypes.h"
#include "AssetUsagePaths.h"
#include "Engine/World.h"
#include "HAL/FileManager.h"
#include "Misc/AutomationTest.h"
#include "Misc/PackageName.h"
#if WITH_DEV_AUTOMATION_TESTS
/**
* The refusals, and the referencer arithmetic behind the warning.
*
* This is the only part of the tool that leads to something being destroyed, so the tests are
* written against the two claims the delete window makes to the user: "these will not be touched",
* and "this many things still point at that". Both are decided here; the window only renders them.
*
* Nothing in this spec deletes anything. AssetDeletionScan cannot - it holds no delete code at all,
* which is exactly why the analysis was split out of the Editor module.
*/
BEGIN_DEFINE_SPEC(AssetDeletionScanSpec,
"AssetUsageAudit.DeletionScan",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
static IAssetRegistry& ReadyRegistry()
{
IAssetRegistry& Registry = IAssetRegistry::GetChecked();
if (Registry.IsLoadingAssets())
{
Registry.WaitForCompletion();
}
return Registry;
}
/** First map in /Game, chosen deterministically so a failure is reproducible. */
static FName FindLevelPackage()
{
IAssetRegistry& Registry = ReadyRegistry();
FARFilter Filter;
Filter.PackagePaths.Add(FName(TEXT("/Game")));
Filter.bRecursivePaths = true;
Filter.bIncludeOnlyOnDiskAssets = true;
Filter.ClassPaths.Add(UWorld::StaticClass()->GetClassPathName());
TArray<FName> Levels;
Registry.EnumerateAssets(Filter, [&Levels](const FAssetData& AssetData)
{
Levels.Add(AssetData.PackageName);
return Levels.Num() < 512;
});
if (Levels.IsEmpty())
{
return NAME_None;
}
Levels.Sort(FNameLexicalLess());
return Levels[0];
}
/** First One File Per Actor package, if this project has any. */
static FName FindExternalPackage()
{
IAssetRegistry& Registry = ReadyRegistry();
FARFilter Filter;
Filter.PackagePaths.Add(FName(TEXT("/Game/__ExternalActors__")));
Filter.bRecursivePaths = true;
Filter.bIncludeOnlyOnDiskAssets = true;
TArray<FName> Found;
Registry.EnumerateAssets(Filter, [&Found](const FAssetData& AssetData)
{
Found.Add(AssetData.PackageName);
return false;
});
return Found.IsEmpty() ? NAME_None : Found[0];
}
/**
* An ordinary asset together with one package that references it.
*
* Both halves are needed to prove the delete-set exclusion: the referencer has to be a real one
* for "referenced from outside" and "referenced from inside the set" to be the same edge seen
* twice, which is the whole point of the check.
*/
static FName FindReferencedAsset(FName& OutReferencer)
{
IAssetRegistry& Registry = ReadyRegistry();
FARFilter Filter;
Filter.PackagePaths.Add(FName(TEXT("/Game")));
Filter.bRecursivePaths = true;
Filter.bIncludeOnlyOnDiskAssets = true;
TArray<FName> Candidates;
Registry.EnumerateAssets(Filter, [&Candidates](const FAssetData& AssetData)
{
if (AssetData.IsRedirector() || AssetData.AssetClassPath == UWorld::StaticClass()->GetClassPathName())
{
return true;
}
if (AssetUsagePaths::IsExternalPackage(AssetData.PackageName))
{
return true;
}
Candidates.Add(AssetData.PackageName);
return Candidates.Num() < 4096;
});
Candidates.Sort(FNameLexicalLess());
TArray<FName> Referencers;
for (FName Candidate : Candidates)
{
Referencers.Reset();
Registry.GetReferencers(Candidate, Referencers,
AssetUsageAudit::MakeTraversalCategory(), AssetUsageAudit::MakeTraversalQuery());
for (FName Referencer : Referencers)
{
if (Referencer != Candidate)
{
OutReferencer = Referencer;
return Candidate;
}
}
}
return NAME_None;
}
END_DEFINE_SPEC(AssetDeletionScanSpec)
void AssetDeletionScanSpec::Define()
{
Describe("Refusing what must not be deleted", [this]()
{
It("refuses a map", [this]()
{
const FName Level = FindLevelPackage();
if (Level.IsNone())
{
AddWarning(TEXT("This project has no maps; skipping."));
return;
}
AssetDeletionScan::FStats Stats;
const TArray<AssetDeletionScan::FCandidate> Candidates =
AssetDeletionScan::Scan(ReadyRegistry(), { Level }, AssetDeletionScan::FOptions(), Stats);
if (Candidates.Num() != 1)
{
AddError(TEXT("expected exactly one candidate"));
return;
}
// The user's rule, and the one refusal that is policy rather than capability: the engine
// would happily delete an unopened map.
TestEqual(TEXT("refused as a level"),
Candidates[0].Refusal, AssetDeletionScan::ERefusal::IsLevel);
TestFalse(TEXT("not deletable"), Candidates[0].CanDelete());
TestEqual(TEXT("counted as a refused level"), Stats.RefusedLevels, 1);
TestEqual(TEXT("nothing deletable"), Stats.Deletable, 0);
});
It("refuses a One File Per Actor package", [this]()
{
const FName External = FindExternalPackage();
if (External.IsNone())
{
AddWarning(TEXT("This project has no external actor packages; skipping."));
return;
}
AssetDeletionScan::FStats Stats;
const TArray<AssetDeletionScan::FCandidate> Candidates =
AssetDeletionScan::Scan(ReadyRegistry(), { External }, AssetDeletionScan::FOptions(), Stats);
if (Candidates.Num() != 1)
{
AddError(TEXT("expected exactly one candidate"));
return;
}
// Deleting one of these is removing a placed actor from a map behind the level editor's
// back - no undo, and the map keeps a reference to a package that no longer exists.
TestEqual(TEXT("refused as external"),
Candidates[0].Refusal, AssetDeletionScan::ERefusal::IsExternalPackage);
TestEqual(TEXT("counted"), Stats.RefusedExternal, 1);
});
It("refuses engine content", [this]()
{
AssetDeletionScan::FStats Stats;
const TArray<AssetDeletionScan::FCandidate> Candidates = AssetDeletionScan::Scan(
ReadyRegistry(),
{ FName(TEXT("/Engine/BasicShapes/Cube")) },
AssetDeletionScan::FOptions(),
Stats);
if (Candidates.Num() != 1)
{
AddError(TEXT("expected exactly one candidate"));
return;
}
TestEqual(TEXT("refused as engine content"),
Candidates[0].Refusal, AssetDeletionScan::ERefusal::NotProjectContent);
TestEqual(TEXT("counted"), Stats.RefusedNotProjectContent, 1);
});
It("refuses a package the registry does not know", [this]()
{
AssetDeletionScan::FStats Stats;
const TArray<AssetDeletionScan::FCandidate> Candidates = AssetDeletionScan::Scan(
ReadyRegistry(),
{ FName(TEXT("/Game/AssetUsageAuditNoSuchPackage")) },
AssetDeletionScan::FOptions(),
Stats);
if (Candidates.Num() != 1)
{
AddError(TEXT("expected exactly one candidate"));
return;
}
TestEqual(TEXT("reported as missing"),
Candidates[0].Refusal, AssetDeletionScan::ERefusal::Missing);
TestEqual(TEXT("counted"), Stats.RefusedMissing, 1);
});
It("keeps refused rows in the result rather than dropping them", [this]()
{
const FName Level = FindLevelPackage();
if (Level.IsNone())
{
AddWarning(TEXT("This project has no maps; skipping."));
return;
}
const FName Missing(TEXT("/Game/AssetUsageAuditNoSuchPackage"));
const FName Engine(TEXT("/Engine/BasicShapes/Cube"));
AssetDeletionScan::FStats Stats;
const TArray<AssetDeletionScan::FCandidate> Candidates = AssetDeletionScan::Scan(
ReadyRegistry(), { Level, Missing, Engine }, AssetDeletionScan::FOptions(), Stats);
// A refusal the user cannot see is indistinguishable from the tool losing their
// selection, so every ticked package must come back with a reason attached.
TestEqual(TEXT("all three returned"), Candidates.Num(), 3);
TestEqual(TEXT("in the original order"), Candidates[0].PackageName, Level);
TestEqual(TEXT("in the original order"), Candidates[1].PackageName, Missing);
TestEqual(TEXT("in the original order"), Candidates[2].PackageName, Engine);
TestEqual(TEXT("requested count matches"), Stats.Requested, 3);
TestEqual(TEXT("none deletable"), Stats.Deletable, 0);
});
});
Describe("Counting what still points at the asset", [this]()
{
It("lists a referencer that is not itself being deleted", [this]()
{
FName Referencer = NAME_None;
const FName Asset = FindReferencedAsset(Referencer);
if (Asset.IsNone())
{
AddWarning(TEXT("No referenced asset was found; skipping."));
return;
}
AssetDeletionScan::FStats Stats;
const TArray<AssetDeletionScan::FCandidate> Candidates =
AssetDeletionScan::Scan(ReadyRegistry(), { Asset }, AssetDeletionScan::FOptions(), Stats);
if (Candidates.Num() != 1 || !Candidates[0].CanDelete())
{
AddError(TEXT("expected one deletable candidate"));
return;
}
TestTrue(TEXT("at least one outside referencer"), Candidates[0].OutsideReferencerCount > 0);
TestTrue(TEXT("the known referencer is listed"),
Candidates[0].OutsideReferencers.Contains(Referencer)
|| Candidates[0].OutsideReferencerCount > Candidates[0].OutsideReferencers.Num());
TestEqual(TEXT("counted in the stats"), Stats.WithOutsideReferencers, 1);
});
It("does not count a referencer that is in the delete set", [this]()
{
FName Referencer = NAME_None;
const FName Asset = FindReferencedAsset(Referencer);
if (Asset.IsNone() || Referencer.IsNone())
{
AddWarning(TEXT("No referenced asset was found; skipping."));
return;
}
AssetDeletionScan::FStats Before;
const TArray<AssetDeletionScan::FCandidate> Alone =
AssetDeletionScan::Scan(ReadyRegistry(), { Asset }, AssetDeletionScan::FOptions(), Before);
AssetDeletionScan::FStats After;
const TArray<AssetDeletionScan::FCandidate> Together =
AssetDeletionScan::Scan(ReadyRegistry(), { Asset, Referencer }, AssetDeletionScan::FOptions(), After);
const AssetDeletionScan::FCandidate* Widened =
Together.FindByPredicate([Asset](const AssetDeletionScan::FCandidate& C) { return C.PackageName == Asset; });
if (Alone.Num() != 1 || !Widened)
{
AddError(TEXT("expected the asset in both scans"));
return;
}
// Deleting a Blueprint together with the mesh only it uses is a clean operation. If the
// Blueprint were still counted, every sensible multi-asset delete would look dangerous
// and the warning would stop meaning anything.
TestTrue(TEXT("the count dropped once its referencer joined the set"),
Widened->OutsideReferencerCount < Alone[0].OutsideReferencerCount);
TestFalse(TEXT("the referencer is no longer listed"),
Widened->OutsideReferencers.Contains(Referencer));
});
It("truncates the listed names but not the count", [this]()
{
FName Referencer = NAME_None;
const FName Asset = FindReferencedAsset(Referencer);
if (Asset.IsNone())
{
AddWarning(TEXT("No referenced asset was found; skipping."));
return;
}
AssetDeletionScan::FOptions Options;
Options.MaxReferencersListed = 1;
AssetDeletionScan::FStats Stats;
const TArray<AssetDeletionScan::FCandidate> Candidates =
AssetDeletionScan::Scan(ReadyRegistry(), { Asset }, Options, Stats);
if (Candidates.Num() != 1)
{
AddError(TEXT("expected one candidate"));
return;
}
TestTrue(TEXT("at most one name kept"), Candidates[0].OutsideReferencers.Num() <= 1);
TestTrue(TEXT("the count is not truncated with it"),
Candidates[0].OutsideReferencerCount >= Candidates[0].OutsideReferencers.Num());
});
});
Describe("Describing a deletable asset", [this]()
{
It("reports the read-only flag the way the filesystem does", [this]()
{
FName Referencer = NAME_None;
const FName Asset = FindReferencedAsset(Referencer);
if (Asset.IsNone())
{
AddWarning(TEXT("No suitable asset was found; skipping."));
return;
}
AssetDeletionScan::FStats Stats;
const TArray<AssetDeletionScan::FCandidate> Candidates =
AssetDeletionScan::Scan(ReadyRegistry(), { Asset }, AssetDeletionScan::FOptions(), Stats);
if (Candidates.Num() != 1 || !Candidates[0].CanDelete())
{
AddError(TEXT("expected one deletable candidate"));
return;
}
FString Filename;
if (!FPackageName::DoesPackageExist(Asset.ToString(), &Filename))
{
AddError(TEXT("the package has no file on disk"));
return;
}
// Perforce-primary projects keep unopened files read-only, so read-only is the normal state of an unopened
// file. Reporting it up front is the difference between a warning and a delete that
// fails halfway through the selection.
TestEqual(TEXT("matches the filesystem"),
Candidates[0].bReadOnlyOnDisk, IFileManager::Get().IsReadOnly(*Filename));
TestEqual(TEXT("read-only count agrees"),
Stats.ReadOnly, Candidates[0].bReadOnlyOnDisk ? 1 : 0);
AddInfo(FString::Printf(TEXT("%s: %d outside referencer(s), %d from levels, read-only %s."),
*Asset.ToString(),
Candidates[0].OutsideReferencerCount,
Candidates[0].LevelReferencerCount,
Candidates[0].bReadOnlyOnDisk ? TEXT("yes") : TEXT("no")));
});
It("fills in the name, class and project-relative path", [this]()
{
FName Referencer = NAME_None;
const FName Asset = FindReferencedAsset(Referencer);
if (Asset.IsNone())
{
AddWarning(TEXT("No suitable asset was found; skipping."));
return;
}
AssetDeletionScan::FStats Stats;
const TArray<AssetDeletionScan::FCandidate> Candidates =
AssetDeletionScan::Scan(ReadyRegistry(), { Asset }, AssetDeletionScan::FOptions(), Stats);
if (Candidates.Num() != 1)
{
AddError(TEXT("expected one candidate"));
return;
}
// The window shows these three and nothing else per row, so an empty one is a blank
// line in front of someone about to press Delete.
TestFalse(TEXT("name filled"), Candidates[0].AssetName.IsNone());
TestTrue(TEXT("class filled"), Candidates[0].ClassPath.IsValid());
TestTrue(TEXT("path starts at Content/"),
Candidates[0].PathFromProjectRoot.StartsWith(TEXT("Content/")));
});
});
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -232,8 +232,8 @@ void AssetDependencyClosureSpec::Define()
It("does not follow a map that an ordinary asset merely mentions", [this]()
{
// The defect this guards against: exporting WP_Example pulled in the whole of L_Other
// through BP_GameMode -> PDA_MenuConfig, 9994 packages belonging to a
// The defect this guards against: exporting WP_Main pulled in the whole of L_MainLevel
// through BP_FirstPersonGameMode -> PDA_MenuSystemConfig, 9994 packages belonging to a
// different location. FLevelUsageResolver already refused that crossing; the closure
// did not, so the two disagreed about the same graph.
IAssetRegistry& Registry = ReadyRegistry();
@@ -176,7 +176,7 @@ BEGIN_DEFINE_SPEC(AssetUsageExporterLiveSpec,
/**
* The OFPA level with the fewest external actors.
*
* Smallest on purpose: WP_Example owns roughly 16000 external packages and copying those in a test
* Smallest on purpose: WP_Main owns roughly 16000 external packages and copying those in a test
* would move gigabytes. Search stops as soon as something small enough turns up.
*/
static FName FindSmallestOfpaLevel(int32& OutExternalCount)
@@ -254,7 +254,7 @@ void AssetUsageExporterLiveSpec::Define()
It("leaves the exported file writable even when the source is read-only", [this]()
{
// Perforce-primary projects keep unopened files read-only, so unopened files are read-only on disk and Windows
// This project is Perforce-primary, so unopened files are read-only on disk and Windows
// CopyFile carries the attribute across. A read-only export folder is useless to an
// artist and breaks the exporter's own Overwrite policy on the next run.
const FName Package = FindOrdinaryAssetPackage();
@@ -16,27 +16,27 @@ void AssetUsagePathsSpec::Define()
{
It("detects an OFPA external actor package", [this]()
{
// Real shape from a live project: /Game/__ExternalActors__/Maps/WP_Example/8/AB/<22 chars>
// Real shape from this project: /Game/__ExternalActors__/Space/Maps/WP_Main/8/AB/<22 chars>
TestTrue(TEXT("external actor"),
AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/__ExternalActors__/Maps/WP_Example/8/AB/QWERTYUIOPASDFGHJKLZXC"))));
AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/__ExternalActors__/Space/Maps/WP_Main/8/AB/QWERTYUIOPASDFGHJKLZXC"))));
});
It("detects an external objects package", [this]()
{
TestTrue(TEXT("external object"),
AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/__ExternalObjects__/Maps/L_3i_Hub/1/2A/ABC"))));
AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/__ExternalObjects__/Space/Maps/L_3i_Hub/1/2A/ABC"))));
});
It("does not flag an ordinary content package", [this]()
{
TestFalse(TEXT("ordinary asset"),
AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/Art/SM_Rock"))));
AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/Space/Art/SM_Rock"))));
});
It("does not flag a level that merely lives next to external actors", [this]()
{
TestFalse(TEXT("the level itself is not external"),
AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/Maps/WP_Example"))));
AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/Space/Maps/WP_Main"))));
});
});
@@ -44,7 +44,7 @@ void AssetUsagePathsSpec::Define()
{
It("flags /Script packages", [this]()
{
TestTrue(TEXT("script"), AssetUsagePaths::IsScriptPackage(FName(TEXT("/Script/MyGame"))));
TestTrue(TEXT("script"), AssetUsagePaths::IsScriptPackage(FName(TEXT("/Script/Space"))));
});
It("does not flag content that starts with the same letters", [this]()
@@ -123,7 +123,7 @@ void AssetUsagePathsSpec::Define()
{
const TArray<FString> Excluded;
TestFalse(TEXT("nothing excluded"),
AssetUsagePaths::IsPathExcluded(FName(TEXT("/Game/Art/SM_Rock")), Excluded));
AssetUsagePaths::IsPathExcluded(FName(TEXT("/Game/Space/Art/SM_Rock")), Excluded));
});
});
@@ -132,21 +132,21 @@ void AssetUsagePathsSpec::Define()
It("converts an asset package", [this]()
{
TestEqual(TEXT("asset"),
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/Art/SM_Rock")), false),
FString(TEXT("Content/Art/SM_Rock.uasset")));
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/Space/Art/SM_Rock")), false),
FString(TEXT("Content/Space/Art/SM_Rock.uasset")));
});
It("uses the umap extension for levels", [this]()
{
TestEqual(TEXT("level"),
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/Maps/WP_Example")), true),
FString(TEXT("Content/Maps/WP_Example.umap")));
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/Space/Maps/WP_Main")), true),
FString(TEXT("Content/Space/Maps/WP_Main.umap")));
});
It("returns empty for script and engine packages rather than inventing a path", [this]()
{
TestTrue(TEXT("script"),
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Script/MyGame")), false).IsEmpty());
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Script/Space")), false).IsEmpty());
TestTrue(TEXT("engine"),
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Engine/BasicShapes/Cube")), false).IsEmpty());
});
@@ -154,8 +154,8 @@ void AssetUsagePathsSpec::Define()
It("converts an external actor package, which is still under /Game", [this]()
{
TestEqual(TEXT("external actor"),
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/__ExternalActors__/Maps/WP_Example/8/AB/ABC")), false),
FString(TEXT("Content/__ExternalActors__/Maps/WP_Example/8/AB/ABC.uasset")));
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/__ExternalActors__/Space/Maps/WP_Main/8/AB/ABC")), false),
FString(TEXT("Content/__ExternalActors__/Space/Maps/WP_Main/8/AB/ABC.uasset")));
});
});
}
@@ -17,7 +17,7 @@ namespace
FAssetUsageAuditResult Result;
Result.Header.GeneratedAt = FDateTime(2026, 9, 2, 12, 0, 0);
Result.Header.ProjectName = TEXT("MyProject");
Result.Header.ProjectName = TEXT("Space");
Result.Header.EngineVersion = TEXT("5.6.1");
Result.Header.ToolVersion = TEXT("0.1");
Result.Header.LevelsScanned = 2;
@@ -25,22 +25,22 @@ namespace
Result.Header.AppliedFilters.Add(TEXT("Types: StaticMesh"));
FAssetUsageRow& Used = Result.Rows.AddDefaulted_GetRef();
Used.PackageName = FName(TEXT("/Game/Art/SM_Rock"));
Used.PackageName = FName(TEXT("/Game/Space/Art/SM_Rock"));
Used.AssetName = FName(TEXT("SM_Rock"));
Used.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine.StaticMesh"));
Used.PathFromProjectRoot = TEXT("Content/Art/SM_Rock.uasset");
Used.PathFromProjectRoot = TEXT("Content/Space/Art/SM_Rock.uasset");
Used.Verdict = EAssetUsageVerdict::UsedOnLevel;
Used.Levels = { FName(TEXT("/Game/Maps/WP_Example")), FName(TEXT("/Game/Maps/L_3i_Hub")) };
Used.Levels = { FName(TEXT("/Game/Space/Maps/WP_Main")), FName(TEXT("/Game/Space/Maps/L_3i_Hub")) };
Used.HardReferenceCount = 3;
Used.SoftReferenceCount = 1;
Used.Provenance = EAssetUsageProvenance::HardReference | EAssetUsageProvenance::ExternalActor;
Used.Route = TEXT("WP_Example -> BP_Rock -> SM_Rock");
Used.Route = TEXT("WP_Main -> BP_Rock -> SM_Rock");
FAssetUsageRow& Unused = Result.Rows.AddDefaulted_GetRef();
Unused.PackageName = FName(TEXT("/Game/Art/SM_Orphan"));
Unused.PackageName = FName(TEXT("/Game/Space/Art/SM_Orphan"));
Unused.AssetName = FName(TEXT("SM_Orphan"));
Unused.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine.StaticMesh"));
Unused.PathFromProjectRoot = TEXT("Content/Art/SM_Orphan.uasset");
Unused.PathFromProjectRoot = TEXT("Content/Space/Art/SM_Orphan.uasset");
Unused.Verdict = EAssetUsageVerdict::Unreferenced;
return Result;
@@ -123,7 +123,7 @@ void AssetUsageReportWriterSpec::Define()
const FString Csv = FAssetUsageReportWriter::BuildCsv(Sample, Options);
TestTrue(TEXT("levels joined"),
Csv.Contains(TEXT("/Game/Maps/WP_Example|/Game/Maps/L_3i_Hub")));
Csv.Contains(TEXT("/Game/Space/Maps/WP_Main|/Game/Space/Maps/L_3i_Hub")));
});
It("carries the verdict verbatim", [this]()
@@ -172,7 +172,7 @@ void AssetUsageReportWriterSpec::Define()
TestTrue(TEXT("header object"), Root->TryGetObjectField(TEXT("header"), Header));
if (Header)
{
TestEqual(TEXT("project"), (*Header)->GetStringField(TEXT("project")), FString(TEXT("MyProject")));
TestEqual(TEXT("project"), (*Header)->GetStringField(TEXT("project")), FString(TEXT("Space")));
TestEqual(TEXT("levelsScanned"), (*Header)->GetIntegerField(TEXT("levelsScanned")), 2);
}
@@ -45,7 +45,7 @@ void AssetUsageVerdictSpec::Define()
It("reports ReferencedFromConfigOrSource when only a text literal names it", [this]()
{
// This is the BP_GameMode case: no asset references it, only
// This is the BP_FirstPersonGameMode case: no asset references it, only
// Config/DefaultEngine.ini does. Reporting it Unreferenced would be a real defect.
const EAssetUsageVerdict Verdict = FAssetUsageAuditor::ClassifyVerdict(false, false, true, false);
@@ -126,7 +126,7 @@ void AssetUsageVerdictSpec::Define()
{
// A level depends on the whole table, so every row looks used even when none is.
FAssetUsageNode Node;
Node.PackageName = FName(TEXT("/Game/Data/DT_Weapons"));
Node.PackageName = FName(TEXT("/Game/Space/Data/DT_Weapons"));
Node.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine.DataTable"));
TestTrue(TEXT("DataTable is a blind spot"), FAssetUsageAuditor::IsRegistryBlindSpot(Node));
@@ -135,7 +135,7 @@ void AssetUsageVerdictSpec::Define()
It("does not flag an ordinary static mesh", [this]()
{
FAssetUsageNode Node;
Node.PackageName = FName(TEXT("/Game/Art/SM_Rock"));
Node.PackageName = FName(TEXT("/Game/Space/Art/SM_Rock"));
Node.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine.StaticMesh"));
TestFalse(TEXT("StaticMesh is not a blind spot"), FAssetUsageAuditor::IsRegistryBlindSpot(Node));
@@ -28,70 +28,70 @@ void IndirectReferenceScannerSpec::Define()
It("finds the GameMode from DefaultEngine.ini and strips the _C class suffix", [this]()
{
const TArray<FString> Found = Extract(
TEXT("GlobalDefaultGameMode=/Game/Core/GameModes/BP_GameMode.BP_GameMode_C"));
TEXT("GlobalDefaultGameMode=/Game/Space/Core/GameModes/BP_FirstPersonGameMode.BP_FirstPersonGameMode_C"));
TestEqual(TEXT("one hit"), Found.Num(), 1);
if (Found.Num() == 1)
{
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Core/GameModes/BP_GameMode")));
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Core/GameModes/BP_FirstPersonGameMode")));
}
});
It("finds the GameInstance from a purchased pack", [this]()
It("finds the GameInstance from MenuSystemPro", [this]()
{
const TArray<FString> Found = Extract(
TEXT("GameInstanceClass=/Game/UIPack/Blueprints/GameFramework/BP_MenuGameInstance.BP_MenuGameInstance_C"));
TEXT("GameInstanceClass=/Game/MenuSystemPro/Blueprints/GameFramework/BP_MenuSystemGameInstance.BP_MenuSystemGameInstance_C"));
TestEqual(TEXT("one hit"), Found.Num(), 1);
if (Found.Num() == 1)
{
TestEqual(TEXT("package"), Found[0],
FString(TEXT("/Game/UIPack/Blueprints/GameFramework/BP_MenuGameInstance")));
FString(TEXT("/Game/MenuSystemPro/Blueprints/GameFramework/BP_MenuSystemGameInstance")));
}
});
It("finds a map reference with an object suffix", [this]()
{
const TArray<FString> Found = Extract(TEXT("EditorStartupMap=/Game/Maps/Gyms/Gyms_Geoda.Gyms_Geoda"));
const TArray<FString> Found = Extract(TEXT("EditorStartupMap=/Game/Space/Maps/Gyms/Gyms_Geoda.Gyms_Geoda"));
TestEqual(TEXT("one hit"), Found.Num(), 1);
if (Found.Num() == 1)
{
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Maps/Gyms/Gyms_Geoda")));
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Maps/Gyms/Gyms_Geoda")));
}
});
It("finds a bare package path with no object suffix", [this]()
{
const TArray<FString> Found = Extract(TEXT("GameDefaultMap=/Game/Maps/RefinedMenuMap"));
const TArray<FString> Found = Extract(TEXT("GameDefaultMap=/Game/Space/Maps/RefinedMenuMap"));
TestEqual(TEXT("one hit"), Found.Num(), 1);
if (Found.Num() == 1)
{
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Maps/RefinedMenuMap")));
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Maps/RefinedMenuMap")));
}
});
It("strips surrounding quotes and parentheses from an ini struct value", [this]()
{
const TArray<FString> Found = Extract(TEXT("+DirectoriesToAlwaysCook=(Path=\"/Game/Art\")"));
const TArray<FString> Found = Extract(TEXT("+DirectoriesToAlwaysCook=(Path=\"/Game/Space/Art\")"));
TestEqual(TEXT("one hit"), Found.Num(), 1);
if (Found.Num() == 1)
{
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Art")));
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Art")));
}
});
It("handles a C++ literal wrapped in TEXT()", [this]()
{
const TArray<FString> Found = Extract(
TEXT("static const FSoftObjectPath Path(TEXT(\"/Game/Art/SM_Rock\"));"));
TEXT("static const FSoftObjectPath Path(TEXT(\"/Game/Space/Art/SM_Rock\"));"));
TestEqual(TEXT("one hit"), Found.Num(), 1);
if (Found.Num() == 1)
{
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Art/SM_Rock")));
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Art/SM_Rock")));
}
});