72bf94d142
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>
447 lines
17 KiB
C++
447 lines
17 KiB
C++
// NextGenium 2026. Asset Usage Audit.
|
|
|
|
#include "AssetUsageExporter.h"
|
|
|
|
#include "AssetRegistry/ARFilter.h"
|
|
#include "AssetRegistry/IAssetRegistry.h"
|
|
#include "Engine/Level.h"
|
|
#include "Engine/World.h"
|
|
#include "HAL/FileManager.h"
|
|
#include "Misc/AutomationTest.h"
|
|
#include "Misc/PackageName.h"
|
|
#include "Misc/Paths.h"
|
|
#include "Misc/SecureHash.h"
|
|
|
|
#if WITH_DEV_AUTOMATION_TESTS
|
|
|
|
/**
|
|
* Exporter tests against the project's real content.
|
|
*
|
|
* The sibling spec (AssetUsageAudit.Exporter) covers the exporter's logic with names that
|
|
* deliberately do not exist, so it proves the bookkeeping and nothing else: until this file existed
|
|
* the exporter had never copied a single real .uasset. The interesting failures - a Perforce
|
|
* read-only source, an OFPA level whose external actors live under a stale duplicate folder, a
|
|
* package whose file extension is .umap rather than .uasset - only appear on real files.
|
|
*
|
|
* Everything here discovers its subject from the Asset Registry rather than hardcoding a path, so
|
|
* the spec keeps working after content moves, and warns instead of failing on a clone that has no
|
|
* suitable asset.
|
|
*/
|
|
BEGIN_DEFINE_SPEC(AssetUsageExporterLiveSpec,
|
|
"AssetUsageAudit.ExporterLive",
|
|
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
|
|
|
|
FString ScratchDir;
|
|
|
|
void MakeScratchDir(const TCHAR* Suffix)
|
|
{
|
|
ScratchDir = FPaths::ConvertRelativePathToFull(
|
|
FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("AssetUsageAuditTests"), Suffix));
|
|
|
|
IFileManager::Get().DeleteDirectory(*ScratchDir, false, true);
|
|
IFileManager::Get().MakeDirectory(*ScratchDir, true);
|
|
}
|
|
|
|
void RemoveScratchDir()
|
|
{
|
|
if (!ScratchDir.IsEmpty())
|
|
{
|
|
IFileManager::Get().DeleteDirectory(*ScratchDir, false, true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A partially scanned registry answers "no such asset" indistinguishably from "no such asset
|
|
* exists", which would turn a real failure into a silent skip.
|
|
*/
|
|
static IAssetRegistry& ReadyRegistry()
|
|
{
|
|
IAssetRegistry& Registry = IAssetRegistry::GetChecked();
|
|
if (Registry.IsLoadingAssets())
|
|
{
|
|
Registry.WaitForCompletion();
|
|
}
|
|
return Registry;
|
|
}
|
|
|
|
/** Files on disk under a package's file, or 0 when the package has no file. */
|
|
static int64 FileSizeOf(FName PackageName)
|
|
{
|
|
const FString Path = FAssetUsageExporter::ResolvePackageFilePath(PackageName);
|
|
return Path.IsEmpty() ? 0 : IFileManager::Get().FileSize(*Path);
|
|
}
|
|
|
|
static FString HashOf(const FString& FilePath)
|
|
{
|
|
FMD5Hash Hash = FMD5Hash::HashFile(*FilePath);
|
|
return Hash.IsValid() ? LexToString(Hash) : FString();
|
|
}
|
|
|
|
/**
|
|
* Any ordinary /Game asset that has a file on disk.
|
|
*
|
|
* Excludes levels (a .umap drags external actors in), OFPA packages, and redirectors. Sorted so
|
|
* two runs on the same content pick the same asset and a failure is reproducible.
|
|
*/
|
|
static FName FindOrdinaryAssetPackage()
|
|
{
|
|
FARFilter Filter;
|
|
Filter.PackagePaths.Add(FName(TEXT("/Game")));
|
|
Filter.bRecursivePaths = true;
|
|
Filter.bIncludeOnlyOnDiskAssets = true;
|
|
|
|
TArray<FName> Candidates;
|
|
|
|
ReadyRegistry().EnumerateAssets(Filter, [&Candidates](const FAssetData& AssetData)
|
|
{
|
|
// Compare class paths rather than calling GetClass(): GetClass() resolves the UClass and
|
|
// returns null for anything not loaded, which would let levels through.
|
|
if (AssetData.IsRedirector() || AssetData.AssetClassPath == UWorld::StaticClass()->GetClassPathName())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
const FString PackageName = AssetData.PackageName.ToString();
|
|
if (PackageName.Contains(TEXT("__External")))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
Candidates.Add(AssetData.PackageName);
|
|
|
|
// A few hundred is plenty to sort; enumerating all 80k just to pick one is waste.
|
|
return Candidates.Num() < 512;
|
|
});
|
|
|
|
if (Candidates.IsEmpty())
|
|
{
|
|
return NAME_None;
|
|
}
|
|
|
|
Candidates.Sort(FNameLexicalLess());
|
|
|
|
for (FName Candidate : Candidates)
|
|
{
|
|
if (!FAssetUsageExporter::ResolvePackageFilePath(Candidate).IsEmpty())
|
|
{
|
|
return Candidate;
|
|
}
|
|
}
|
|
|
|
return NAME_None;
|
|
}
|
|
|
|
/**
|
|
* How many .uasset files live under a level's registered external roots.
|
|
*
|
|
* Uses the plural GetExternalActorsPaths / GetExternalObjectsPaths rather than a hand-built
|
|
* "__ExternalActors__/..." string. This project contains a stale duplicate of exactly that
|
|
* shape - Content/__ExternalActors__/levelinterior_station alongside the live
|
|
* Content/__ExternalActors__/Interior_station/levelinterior_station - so a string-built path
|
|
* would silently count the wrong folder.
|
|
*/
|
|
static int32 CountExternalPackages(FName LevelPackage)
|
|
{
|
|
const FString LevelPackageStr = LevelPackage.ToString();
|
|
|
|
TArray<FString> Roots = ULevel::GetExternalActorsPaths(LevelPackageStr);
|
|
Roots.Append(ULevel::GetExternalObjectsPaths(LevelPackageStr));
|
|
|
|
// The returned roots overlap: measured on this project, summing the per-root file counts
|
|
// gives exactly twice the number of packages that exist. Deduplicate by absolute file path
|
|
// rather than by package name, so this stays an independent filesystem measurement instead
|
|
// of a restatement of the exporter's own bookkeeping.
|
|
TSet<FString> UniqueFiles;
|
|
|
|
for (const FString& Root : Roots)
|
|
{
|
|
FString RootDir;
|
|
if (!FPackageName::TryConvertLongPackageNameToFilename(Root, RootDir))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
TArray<FString> Found;
|
|
IFileManager::Get().FindFilesRecursive(Found, *RootDir, TEXT("*.uasset"), true, false);
|
|
|
|
for (const FString& File : Found)
|
|
{
|
|
UniqueFiles.Add(FPaths::ConvertRelativePathToFull(File));
|
|
}
|
|
}
|
|
|
|
return UniqueFiles.Num();
|
|
}
|
|
|
|
/**
|
|
* The OFPA level with the fewest external actors.
|
|
*
|
|
* 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)
|
|
{
|
|
FARFilter Filter;
|
|
Filter.ClassPaths.Add(UWorld::StaticClass()->GetClassPathName());
|
|
Filter.PackagePaths.Add(FName(TEXT("/Game")));
|
|
Filter.bRecursivePaths = true;
|
|
Filter.bIncludeOnlyOnDiskAssets = true;
|
|
|
|
FName Best = NAME_None;
|
|
int32 BestCount = MAX_int32;
|
|
|
|
ReadyRegistry().EnumerateAssets(Filter, [&Best, &BestCount](const FAssetData& AssetData)
|
|
{
|
|
const int32 Count = CountExternalPackages(AssetData.PackageName);
|
|
if (Count > 0 && Count < BestCount)
|
|
{
|
|
BestCount = Count;
|
|
Best = AssetData.PackageName;
|
|
}
|
|
|
|
// Good enough to prove the expansion works without a long copy.
|
|
return BestCount > 40;
|
|
});
|
|
|
|
OutExternalCount = (Best.IsNone()) ? 0 : BestCount;
|
|
return Best;
|
|
}
|
|
|
|
int32 CountFilesInScratch() const
|
|
{
|
|
TArray<FString> Found;
|
|
IFileManager::Get().FindFilesRecursive(Found, *ScratchDir, TEXT("*.*"), true, false);
|
|
return Found.Num();
|
|
}
|
|
|
|
END_DEFINE_SPEC(AssetUsageExporterLiveSpec)
|
|
|
|
void AssetUsageExporterLiveSpec::Define()
|
|
{
|
|
Describe("Copying a real asset", [this]()
|
|
{
|
|
AfterEach([this]() { RemoveScratchDir(); });
|
|
|
|
It("reproduces the source file byte for byte", [this]()
|
|
{
|
|
const FName Package = FindOrdinaryAssetPackage();
|
|
if (Package.IsNone())
|
|
{
|
|
AddWarning(TEXT("No ordinary /Game asset with a file on disk was found; skipping."));
|
|
return;
|
|
}
|
|
|
|
MakeScratchDir(TEXT("LiveCopy"));
|
|
|
|
FAssetUsageExporter::FOptions Options;
|
|
Options.TargetDirectory = ScratchDir;
|
|
Options.bIncludeExternalPackages = false;
|
|
|
|
const FAssetUsageExporter::FResult Result =
|
|
FAssetUsageExporter::ExportPackageFiles({ Package }, Options);
|
|
|
|
TestTrue(TEXT("succeeded"), Result.bSuccess);
|
|
TestEqual(TEXT("one file copied"), Result.FilesCopied, 1);
|
|
TestEqual(TEXT("nothing missing"), Result.FilesMissingOnDisk, 0);
|
|
|
|
const FString SourcePath = FAssetUsageExporter::ResolvePackageFilePath(Package);
|
|
const FString DestPath = FPaths::Combine(ScratchDir, FPaths::GetCleanFilename(SourcePath));
|
|
|
|
TestTrue(TEXT("destination exists"), IFileManager::Get().FileExists(*DestPath));
|
|
TestEqual(TEXT("same size"), IFileManager::Get().FileSize(*DestPath), FileSizeOf(Package));
|
|
TestEqual(TEXT("same content"), HashOf(DestPath), HashOf(SourcePath));
|
|
});
|
|
|
|
It("leaves the exported file writable even when the source is read-only", [this]()
|
|
{
|
|
// 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();
|
|
if (Package.IsNone())
|
|
{
|
|
AddWarning(TEXT("No ordinary /Game asset with a file on disk was found; skipping."));
|
|
return;
|
|
}
|
|
|
|
MakeScratchDir(TEXT("LiveReadOnly"));
|
|
|
|
FAssetUsageExporter::FOptions Options;
|
|
Options.TargetDirectory = ScratchDir;
|
|
Options.bIncludeExternalPackages = false;
|
|
|
|
FAssetUsageExporter::ExportPackageFiles({ Package }, Options);
|
|
|
|
const FString DestPath = FPaths::Combine(
|
|
ScratchDir, FPaths::GetCleanFilename(FAssetUsageExporter::ResolvePackageFilePath(Package)));
|
|
|
|
TestFalse(TEXT("destination is writable"), IFileManager::Get().IsReadOnly(*DestPath));
|
|
});
|
|
});
|
|
|
|
Describe("Collision policy on real files", [this]()
|
|
{
|
|
AfterEach([this]() { RemoveScratchDir(); });
|
|
|
|
It("writes a second copy beside the first under the Index policy", [this]()
|
|
{
|
|
const FName Package = FindOrdinaryAssetPackage();
|
|
if (Package.IsNone())
|
|
{
|
|
AddWarning(TEXT("No ordinary /Game asset with a file on disk was found; skipping."));
|
|
return;
|
|
}
|
|
|
|
MakeScratchDir(TEXT("LiveIndex"));
|
|
|
|
FAssetUsageExporter::FOptions Options;
|
|
Options.TargetDirectory = ScratchDir;
|
|
Options.CollisionPolicy = EExportCollisionPolicy::Index;
|
|
Options.bIncludeExternalPackages = false;
|
|
|
|
FAssetUsageExporter::ExportPackageFiles({ Package }, Options);
|
|
const FAssetUsageExporter::FResult Second =
|
|
FAssetUsageExporter::ExportPackageFiles({ Package }, Options);
|
|
const FAssetUsageExporter::FResult Third =
|
|
FAssetUsageExporter::ExportPackageFiles({ Package }, Options);
|
|
|
|
TestEqual(TEXT("second run renamed"), Second.FilesRenamed, 1);
|
|
TestEqual(TEXT("third run renamed"), Third.FilesRenamed, 1);
|
|
TestEqual(TEXT("three files on disk"), CountFilesInScratch(), 3);
|
|
|
|
const FString SourcePath = FAssetUsageExporter::ResolvePackageFilePath(Package);
|
|
const FString Base = FPaths::GetBaseFilename(SourcePath);
|
|
const FString Ext = FPaths::GetExtension(SourcePath, true);
|
|
|
|
TestTrue(TEXT("_1 present"), IFileManager::Get().FileExists(*FPaths::Combine(ScratchDir, Base + TEXT("_1") + Ext)));
|
|
TestTrue(TEXT("_2 present"), IFileManager::Get().FileExists(*FPaths::Combine(ScratchDir, Base + TEXT("_2") + Ext)));
|
|
});
|
|
|
|
It("replaces the previous file under the Overwrite policy", [this]()
|
|
{
|
|
const FName Package = FindOrdinaryAssetPackage();
|
|
if (Package.IsNone())
|
|
{
|
|
AddWarning(TEXT("No ordinary /Game asset with a file on disk was found; skipping."));
|
|
return;
|
|
}
|
|
|
|
MakeScratchDir(TEXT("LiveOverwrite"));
|
|
|
|
FAssetUsageExporter::FOptions Options;
|
|
Options.TargetDirectory = ScratchDir;
|
|
Options.CollisionPolicy = EExportCollisionPolicy::Overwrite;
|
|
Options.bIncludeExternalPackages = false;
|
|
|
|
FAssetUsageExporter::ExportPackageFiles({ Package }, Options);
|
|
const FAssetUsageExporter::FResult Second =
|
|
FAssetUsageExporter::ExportPackageFiles({ Package }, Options);
|
|
|
|
TestTrue(TEXT("second run succeeded"), Second.bSuccess);
|
|
TestEqual(TEXT("counted as an overwrite"), Second.FilesOverwritten, 1);
|
|
TestEqual(TEXT("nothing renamed"), Second.FilesRenamed, 0);
|
|
TestEqual(TEXT("still one file"), CountFilesInScratch(), 1);
|
|
});
|
|
});
|
|
|
|
Describe("Mirroring the package tree", [this]()
|
|
{
|
|
AfterEach([this]() { RemoveScratchDir(); });
|
|
|
|
It("places the file under its /Game path when flattening is off", [this]()
|
|
{
|
|
const FName Package = FindOrdinaryAssetPackage();
|
|
if (Package.IsNone())
|
|
{
|
|
AddWarning(TEXT("No ordinary /Game asset with a file on disk was found; skipping."));
|
|
return;
|
|
}
|
|
|
|
MakeScratchDir(TEXT("LiveTree"));
|
|
|
|
FAssetUsageExporter::FOptions Options;
|
|
Options.TargetDirectory = ScratchDir;
|
|
Options.bFlatten = false;
|
|
Options.bIncludeExternalPackages = false;
|
|
|
|
const FAssetUsageExporter::FResult Result =
|
|
FAssetUsageExporter::ExportPackageFiles({ Package }, Options);
|
|
|
|
TestEqual(TEXT("one file copied"), Result.FilesCopied, 1);
|
|
|
|
FString Relative = FPaths::GetPath(Package.ToString());
|
|
Relative.RemoveFromStart(TEXT("/"));
|
|
|
|
const FString SourcePath = FAssetUsageExporter::ResolvePackageFilePath(Package);
|
|
const FString Expected = FPaths::Combine(ScratchDir, Relative, FPaths::GetCleanFilename(SourcePath));
|
|
|
|
TestTrue(TEXT("mirrored path exists"), IFileManager::Get().FileExists(*Expected));
|
|
});
|
|
});
|
|
|
|
Describe("Exporting an OFPA level", [this]()
|
|
{
|
|
AfterEach([this]() { RemoveScratchDir(); });
|
|
|
|
It("brings every external actor package along with the map", [this]()
|
|
{
|
|
// The whole point of the tool: an OFPA map copied on its own opens empty, because its
|
|
// actors are separate packages joined to it by a soft dependency edge.
|
|
int32 ExpectedExternal = 0;
|
|
const FName Level = FindSmallestOfpaLevel(ExpectedExternal);
|
|
|
|
if (Level.IsNone())
|
|
{
|
|
AddWarning(TEXT("No level with external actor packages was found; skipping."));
|
|
return;
|
|
}
|
|
|
|
AddInfo(FString::Printf(TEXT("Using level '%s' with %d external packages."), *Level.ToString(), ExpectedExternal));
|
|
|
|
MakeScratchDir(TEXT("LiveOfpa"));
|
|
|
|
FAssetUsageExporter::FOptions Options;
|
|
Options.TargetDirectory = ScratchDir;
|
|
Options.bFlatten = false; // keep the tree: external actor names are GUID-ish and long
|
|
Options.bIncludeExternalPackages = true;
|
|
|
|
const FAssetUsageExporter::FResult Result =
|
|
FAssetUsageExporter::ExportPackageFiles({ Level }, Options);
|
|
|
|
TestTrue(TEXT("succeeded"), Result.bSuccess);
|
|
TestEqual(TEXT("expanded to every external package"), Result.ExternalPackagesCopied, ExpectedExternal);
|
|
TestEqual(TEXT("copied the map plus its externals"), Result.FilesCopied, ExpectedExternal + 1);
|
|
TestEqual(TEXT("files on disk match"), CountFilesInScratch(), ExpectedExternal + 1);
|
|
|
|
const FString MapPath = FAssetUsageExporter::ResolvePackageFilePath(Level);
|
|
TestTrue(TEXT("the map itself is a .umap"), MapPath.EndsWith(TEXT(".umap")));
|
|
});
|
|
|
|
It("copies only the map when external packages are switched off", [this]()
|
|
{
|
|
int32 ExpectedExternal = 0;
|
|
const FName Level = FindSmallestOfpaLevel(ExpectedExternal);
|
|
|
|
if (Level.IsNone())
|
|
{
|
|
AddWarning(TEXT("No level with external actor packages was found; skipping."));
|
|
return;
|
|
}
|
|
|
|
MakeScratchDir(TEXT("LiveOfpaOff"));
|
|
|
|
FAssetUsageExporter::FOptions Options;
|
|
Options.TargetDirectory = ScratchDir;
|
|
Options.bIncludeExternalPackages = false;
|
|
|
|
const FAssetUsageExporter::FResult Result =
|
|
FAssetUsageExporter::ExportPackageFiles({ Level }, Options);
|
|
|
|
TestEqual(TEXT("just the map"), Result.FilesCopied, 1);
|
|
TestEqual(TEXT("no externals expanded"), Result.ExternalPackagesCopied, 0);
|
|
});
|
|
});
|
|
}
|
|
|
|
#endif // WITH_DEV_AUTOMATION_TESTS
|