Files
AssetUsageAudit/Source/AssetUsageAuditCore/Private/AssetDeletionScan.cpp
T
MagentaDolphin ee1c8bad3e 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>
2026-09-07 17:14:39 +07:00

244 lines
8.5 KiB
C++

// 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