feat: asset deletion scan and delete dialog (WIP)

Adds AssetDeletionScan to Core (no UI/editor-pipeline deps, invariant holds)
and SAssetDeleteDialog to the Editor module, plus 10 spec cases.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-07 17:14:39 +07:00
parent 243bb1fc55
commit ee1c8bad3e
8 changed files with 1734 additions and 0 deletions
@@ -0,0 +1,243 @@
// NextGenium 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
@@ -0,0 +1,151 @@
// NextGenium 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);
}