Files
MagentaDolphin e04f4dac78 docs: anonymise project asset names in source comments
Measured figures are kept; only the studio-specific level and asset
names are generalised, and the Perforce rationale is reworded to
describe the class of setups rather than this one project.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-07 18:39:38 +07:00

602 lines
22 KiB
C++

// MagentaDolphin 2026. Asset Usage Audit.
#include "AssetUsageExporter.h"
#include "AssetExportNaming.h"
#include "AssetUsageAuditCoreModule.h"
#include "AssetUsagePaths.h"
#include "AssetExportTask.h"
#include "Engine/Level.h"
#include "Exporters/Exporter.h"
#include "HAL/FileManager.h"
#include "HAL/PlatformFileManager.h"
#include "Misc/PackageName.h"
#include "Misc/Paths.h"
#include "UObject/Class.h"
#include "UObject/StrongObjectPtr.h"
#include "UObject/UObjectGlobals.h"
FString FAssetUsageExporter::FResult::Summarise() const
{
TArray<FString> Parts;
Parts.Add(FString::Printf(TEXT("%d copied"), FilesCopied));
if (FilesRenamed > 0)
{
Parts.Add(FString::Printf(TEXT("%d renamed to avoid a collision"), FilesRenamed));
}
if (FilesOverwritten > 0)
{
Parts.Add(FString::Printf(TEXT("%d overwritten"), FilesOverwritten));
}
if (ExternalPackagesCopied > 0)
{
Parts.Add(FString::Printf(TEXT("%d external actor packages"), ExternalPackagesCopied));
}
if (FilesMissingOnDisk > 0)
{
Parts.Add(FString::Printf(TEXT("%d had no file on disk"), FilesMissingOnDisk));
}
if (Errors.Num() > 0)
{
Parts.Add(FString::Printf(TEXT("%d failed"), Errors.Num()));
}
if (bCancelled)
{
Parts.Add(TEXT("cancelled before finishing"));
}
return FString::Join(Parts, TEXT(", "));
}
FString FAssetUsageExporter::FExchangeResult::Summarise() const
{
TArray<FString> Parts;
Parts.Add(FString::Printf(TEXT("%d written"), FilesWritten));
if (FilesRenamed > 0)
{
Parts.Add(FString::Printf(TEXT("%d renamed to avoid a collision"), FilesRenamed));
}
// Each skip reason is reported separately on purpose. "17 skipped" tells a user nothing, while
// "17 have no configured format" points straight at the settings they need to change.
if (SkippedNoFormat > 0)
{
Parts.Add(FString::Printf(TEXT("%d had no configured format"), SkippedNoFormat));
}
if (SkippedNoExporter > 0)
{
Parts.Add(FString::Printf(TEXT("%d had no exporter for that format"), SkippedNoExporter));
}
if (SkippedNotLoaded > 0)
{
Parts.Add(FString::Printf(TEXT("%d could not be loaded"), SkippedNotLoaded));
}
if (Errors.Num() > 0)
{
Parts.Add(FString::Printf(TEXT("%d failed"), Errors.Num()));
}
if (bCancelled)
{
Parts.Add(TEXT("cancelled before finishing"));
}
return FString::Join(Parts, TEXT(", "));
}
TMap<FString, FString> FAssetUsageExporter::GetDefaultFormatByClass()
{
// Only classes the engine actually ships a UExporter for. A plausible-looking entry with no
// exporter behind it turns "nothing came out" into a mystery, so anything unverified is left
// out and reported as SkippedNoFormat instead.
//
// Keys are class short names and lookup walks up the hierarchy, so MaterialInterface covers
// material instances and Texture covers every 2D texture subclass.
return {
{ TEXT("StaticMesh"), TEXT("fbx") },
{ TEXT("SkeletalMesh"), TEXT("fbx") },
{ TEXT("AnimSequence"), TEXT("fbx") },
{ TEXT("Texture2D"), TEXT("png") },
{ TEXT("TextureCube"), TEXT("hdr") },
{ TEXT("SoundWave"), TEXT("wav") },
{ TEXT("DataTable"), TEXT("csv") },
{ TEXT("CurveTable"), TEXT("csv") },
{ TEXT("FontFace"), TEXT("ttf") },
{ TEXT("World"), TEXT("fbx") }
};
}
FString FAssetUsageExporter::FindFormatForClass(const FTopLevelAssetPath& ClassPath, const TMap<FString, FString>& FormatByClass)
{
if (!ClassPath.IsValid())
{
return FString();
}
const FString ShortName = ClassPath.GetAssetName().ToString();
if (const FString* Direct = FormatByClass.Find(ShortName))
{
return *Direct;
}
// Walk the hierarchy so one entry covers a family. FindObject rather than LoadObject: a native
// class is already loaded, and a Blueprint class we cannot find has no exporter anyway.
UClass* Class = FindObject<UClass>(ClassPath);
for (UClass* Super = Class ? Class->GetSuperClass() : nullptr; Super; Super = Super->GetSuperClass())
{
if (const FString* Found = FormatByClass.Find(Super->GetName()))
{
return *Found;
}
}
return FString();
}
FAssetUsageExporter::FExchangeResult FAssetUsageExporter::ExportConvertedAssets(const TArray<FExportItem>& Items, const FExchangeOptions& Options)
{
FExchangeResult Result;
if (Options.TargetDirectory.IsEmpty())
{
Result.Errors.Add(TEXT("No target directory was given."));
UE_LOG(LogAssetUsageAudit, Error, TEXT("Conversion aborted: no target directory was given."));
return Result;
}
IFileManager& FileManager = IFileManager::Get();
if (!FileManager.DirectoryExists(*Options.TargetDirectory)
&& !FileManager.MakeDirectory(*Options.TargetDirectory, /*Tree=*/true))
{
Result.Errors.Add(FString::Printf(TEXT("Could not create '%s'."), *Options.TargetDirectory));
UE_LOG(LogAssetUsageAudit, Error, TEXT("Conversion aborted: could not create target directory '%s'."), *Options.TargetDirectory);
return Result;
}
const TMap<FString, FString>& FormatByClass = Options.FormatByClass.Num() > 0
? Options.FormatByClass
: GetDefaultFormatByClass();
const int32 Total = Items.Num();
int32 LoadedSinceCollect = 0;
for (int32 Index = 0; Index < Total; ++Index)
{
if (Options.OnProgress && !Options.OnProgress(Index, Total))
{
Result.bCancelled = true;
break;
}
const FExportItem& Item = Items[Index];
const FString Extension = FindFormatForClass(Item.ClassPath, FormatByClass);
if (Extension.IsEmpty())
{
// Decided before loading anything: there is no point paying to load a Blueprint just to
// discover the engine cannot write it to a file.
++Result.SkippedNoFormat;
continue;
}
const FString ObjectPath = FString::Printf(TEXT("%s.%s"), *Item.PackageName.ToString(), *Item.AssetName.ToString());
UObject* Asset = LoadObject<UObject>(nullptr, *ObjectPath);
if (!Asset)
{
++Result.SkippedNotLoaded;
UE_LOG(LogAssetUsageAudit, Warning, TEXT("Could not load '%s' for conversion."), *ObjectPath);
continue;
}
FString DestinationDir = Options.TargetDirectory;
if (!Options.bFlatten)
{
FString Relative = FPaths::GetPath(Item.PackageName.ToString());
Relative.RemoveFromStart(TEXT("/"));
DestinationDir = FPaths::Combine(Options.TargetDirectory, Relative);
if (!FileManager.DirectoryExists(*DestinationDir) && !FileManager.MakeDirectory(*DestinationDir, true))
{
Result.Errors.Add(FString::Printf(TEXT("Could not create '%s'."), *DestinationDir));
continue;
}
}
const FString DesiredFileName = FString::Printf(TEXT("%s.%s"), *Item.AssetName.ToString(), *Extension);
const FString FinalFileName = AssetExportNaming::ResolveCollision(
DesiredFileName,
Options.CollisionPolicy,
[&FileManager, &DestinationDir](const FString& Candidate)
{
return FileManager.FileExists(*FPaths::Combine(DestinationDir, Candidate));
});
const FString DestinationPath = FPaths::Combine(DestinationDir, FinalFileName);
if (FileManager.FileExists(*DestinationPath) && FileManager.IsReadOnly(*DestinationPath))
{
// IFileManager can report the flag but not change it; only IPlatformFile can.
FPlatformFileManager::Get().GetPlatformFile().SetReadOnly(*DestinationPath, false);
}
// TStrongObjectPtr, not a bare NewObject: this loop runs garbage collection, and a task
// rooted nowhere would be destroyed underneath the exporter.
TStrongObjectPtr<UAssetExportTask> Task(NewObject<UAssetExportTask>());
Task->Object = Asset;
Task->Exporter = nullptr; // let the engine match SupportedClass against the extension
Task->Filename = DestinationPath;
Task->bSelected = false;
Task->bReplaceIdentical = true;
Task->bPrompt = false; // a modal dialog per asset would hang an unattended run
Task->bAutomated = true;
Task->bUseFileArchive = false;
Task->bWriteEmptyFiles = false;
const bool bRan = UExporter::RunAssetExportTask(Task.Get());
// Both halves matter. RunAssetExportTask returns false when no exporter matched, and it can
// also return true having filled Errors - reading only the bool reports a success that
// produced nothing.
if (!bRan)
{
if (Task->Errors.Num() > 0)
{
for (const FString& TaskError : Task->Errors)
{
Result.Errors.Add(FString::Printf(TEXT("%s: %s"), *Item.AssetName.ToString(), *TaskError));
}
}
else
{
++Result.SkippedNoExporter;
}
}
else if (Task->Errors.Num() > 0)
{
for (const FString& TaskError : Task->Errors)
{
Result.Errors.Add(FString::Printf(TEXT("%s: %s"), *Item.AssetName.ToString(), *TaskError));
}
}
else if (!FileManager.FileExists(*DestinationPath))
{
// An exporter that reports success without producing a file is the one failure mode a
// caller would never notice. Say so rather than counting a write that did not happen.
Result.Errors.Add(FString::Printf(
TEXT("%s: the exporter reported success but wrote no file at '%s'."),
*Item.AssetName.ToString(), *DestinationPath));
}
else
{
++Result.FilesWritten;
if (FinalFileName != DesiredFileName)
{
++Result.FilesRenamed;
}
}
// Loading thousands of meshes without collecting exhausts memory long before the export
// finishes. The task is released first so it is not what keeps the asset alive.
Task.Reset();
if (Options.CollectGarbageEvery > 0 && ++LoadedSinceCollect >= Options.CollectGarbageEvery)
{
LoadedSinceCollect = 0;
CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS);
}
}
Result.bSuccess = Result.Errors.IsEmpty() && !Result.bCancelled;
UE_LOG(LogAssetUsageAudit, Log, TEXT("Conversion to '%s': %s"), *Options.TargetDirectory, *Result.Summarise());
for (const FString& Error : Result.Errors)
{
UE_LOG(LogAssetUsageAudit, Warning, TEXT(" %s"), *Error);
}
return Result;
}
FString FAssetUsageExporter::ResolvePackageFilePath(FName PackageName)
{
const FString PackageNameStr = PackageName.ToString();
if (!FPackageName::IsValidLongPackageName(PackageNameStr))
{
return FString();
}
// A package is either an asset or a map and we do not know which from the name alone.
// DoesPackageExist resolves the extension for us and also copes with mount points other
// than /Game, which matters for plugin content.
FString ResolvedFileName;
if (FPackageName::DoesPackageExist(PackageNameStr, &ResolvedFileName))
{
return ResolvedFileName;
}
return FString();
}
FAssetUsageExporter::FResult FAssetUsageExporter::ExportPackageFiles(const TArray<FName>& PackageNames, const FOptions& Options)
{
// One unnamed group: everything straight into the target, which is what this function has
// always done. Kept as its own entry point because most callers and every existing spec want
// exactly this and should not have to build a group to say so.
FExportGroup Group;
Group.Packages = PackageNames;
return ExportPackageGroups({ MoveTemp(Group) }, Options);
}
FAssetUsageExporter::FResult FAssetUsageExporter::ExportPackageGroups(const TArray<FExportGroup>& Groups, const FOptions& Options)
{
FResult Result;
// Setup failures return before the summary log at the bottom of this function, so they must
// log for themselves. These two are the most likely things to go wrong and were previously
// the only failures that left no trace at all - the UI would say "see the Output Log" and the
// Output Log would be empty.
if (Options.TargetDirectory.IsEmpty())
{
Result.Errors.Add(TEXT("No target directory was given."));
UE_LOG(LogAssetUsageAudit, Error, TEXT("Export aborted: no target directory was given."));
return Result;
}
IFileManager& FileManager = IFileManager::Get();
if (!FileManager.DirectoryExists(*Options.TargetDirectory))
{
if (!FileManager.MakeDirectory(*Options.TargetDirectory, /*Tree=*/true))
{
Result.Errors.Add(FString::Printf(TEXT("Could not create '%s'."), *Options.TargetDirectory));
UE_LOG(LogAssetUsageAudit, Error,
TEXT("Export aborted: could not create target directory '%s'. Check the path is valid and writable ")
TEXT("- a Perforce-read-only parent or a non-existent drive letter both land here."),
*Options.TargetDirectory);
return Result;
}
}
// Flatten the groups into one work list, remembering each entry's destination folder. Doing it
// up front keeps the progress total honest: a caller showing a bar must not watch the maximum
// creep upward as levels expand mid-copy.
//
// Deduplication is per group, not global. The same texture legitimately appears in several
// groups under the folder-per-asset layout - that is the whole point of that layout - so a
// global Seen set would silently give the first folder the texture and leave the rest without.
TArray<FName> Expanded;
TArray<FString> DestinationDirs;
// Parallel to Expanded: true for entries this function added by expanding a level, rather than
// entries the caller asked for. Counted at copy time, not here - reporting "N external actor
// packages" for packages that were only queued would overstate a cancelled or failing run.
TBitArray<> IsExternalExpansion;
for (const FExportGroup& Group : Groups)
{
const FString GroupDir = Group.RelativeDir.IsEmpty()
? Options.TargetDirectory
: FPaths::Combine(Options.TargetDirectory, Group.RelativeDir);
const int32 FirstIndexInGroup = Expanded.Num();
TSet<FName> Seen;
Seen.Reserve(Group.Packages.Num());
for (FName PackageName : Group.Packages)
{
if (!Seen.Contains(PackageName))
{
Seen.Add(PackageName);
Expanded.Add(PackageName);
DestinationDirs.Add(GroupDir);
IsExternalExpansion.Add(false);
}
}
if (!Options.bIncludeExternalPackages)
{
continue;
}
const int32 LastIndexInGroup = Expanded.Num();
for (int32 Index = FirstIndexInGroup; Index < LastIndexInGroup; ++Index)
{
const FString PackageNameStr = Expanded[Index].ToString();
// Only a World owns external packages. Asking for the paths of a non-level is harmless
// but pointless, and on 45k rows it is not free.
const FString FilePath = ResolvePackageFilePath(Expanded[Index]);
if (!FilePath.EndsWith(FPackageName::GetMapPackageExtension()))
{
continue;
}
// Plural form: plugins register extra roots through delegates, and Content Bundles and
// External Data Layers inject path segments a hand-built string would miss.
TArray<FString> ExternalRoots = ULevel::GetExternalActorsPaths(PackageNameStr);
ExternalRoots.Append(ULevel::GetExternalObjectsPaths(PackageNameStr));
for (const FString& Root : ExternalRoots)
{
FString RootDir;
if (!FPackageName::TryConvertLongPackageNameToFilename(Root, RootDir))
{
continue;
}
TArray<FString> Found;
FileManager.FindFilesRecursive(Found, *RootDir, TEXT("*.uasset"), /*Files=*/true, /*Directories=*/false);
for (const FString& AbsFile : Found)
{
FString ExternalPackageName;
if (FPackageName::TryConvertFilenameToLongPackageName(AbsFile, ExternalPackageName))
{
const FName AsName(*ExternalPackageName);
if (!Seen.Contains(AsName))
{
Seen.Add(AsName);
Expanded.Add(AsName);
DestinationDirs.Add(GroupDir);
IsExternalExpansion.Add(true);
}
}
}
}
}
}
return CopyExpandedPackages(Expanded, DestinationDirs, IsExternalExpansion, Options, Result);
}
FAssetUsageExporter::FResult FAssetUsageExporter::CopyExpandedPackages(const TArray<FName>& Expanded, const TArray<FString>& DestinationDirs, const TBitArray<>& IsExternalExpansion, const FOptions& Options, FResult& Result)
{
IFileManager& FileManager = IFileManager::Get();
const int32 Total = Expanded.Num();
for (int32 Index = 0; Index < Total; ++Index)
{
if (Options.OnProgress && !Options.OnProgress(Index, Total))
{
Result.bCancelled = true;
break;
}
const FName PackageName = Expanded[Index];
const FString SourcePath = ResolvePackageFilePath(PackageName);
if (SourcePath.IsEmpty())
{
// Normal for script packages and for assets that exist only in memory. Counted, not
// reported as an error, so a real failure stays visible in the Errors list.
++Result.FilesMissingOnDisk;
continue;
}
// The group decided the folder; bFlatten only chooses whether to mirror the package path
// beneath it. The two compose: a folder-per-asset export with mirroring off writes straight
// into the asset's folder, which is what makes that layout readable.
FString DestinationDir = DestinationDirs.IsValidIndex(Index)
? DestinationDirs[Index]
: Options.TargetDirectory;
if (!Options.bFlatten)
{
// Mirror the package path under the target, minus the leading mount point slash.
FString Relative = FPaths::GetPath(PackageName.ToString());
Relative.RemoveFromStart(TEXT("/"));
DestinationDir = FPaths::Combine(DestinationDir, Relative);
}
if (DestinationDir != Options.TargetDirectory)
{
if (!FileManager.DirectoryExists(*DestinationDir) && !FileManager.MakeDirectory(*DestinationDir, true))
{
Result.Errors.Add(FString::Printf(TEXT("Could not create '%s'."), *DestinationDir));
continue;
}
}
const FString DesiredFileName = FPaths::GetCleanFilename(SourcePath);
const FString FinalFileName = AssetExportNaming::ResolveCollision(
DesiredFileName,
Options.CollisionPolicy,
[&FileManager, &DestinationDir](const FString& Candidate)
{
return FileManager.FileExists(*FPaths::Combine(DestinationDir, Candidate));
});
const FString DestinationPath = FPaths::Combine(DestinationDir, FinalFileName);
const bool bWasPresent = FileManager.FileExists(*DestinationPath);
// Perforce-primary repositories keep unopened files read-only, 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
// after writing a new one. The destination is outside source control by definition.
if (bWasPresent && FileManager.IsReadOnly(*DestinationPath))
{
// IFileManager can report the flag but not change it; only IPlatformFile can.
FPlatformFileManager::Get().GetPlatformFile().SetReadOnly(*DestinationPath, false);
}
if (FileManager.Copy(*DestinationPath, *SourcePath, /*bReplace=*/true) != COPY_OK)
{
Result.Errors.Add(FString::Printf(TEXT("Failed to copy '%s' to '%s'."), *PackageName.ToString(), *DestinationPath));
continue;
}
if (FileManager.IsReadOnly(*DestinationPath))
{
// IFileManager can report the flag but not change it; only IPlatformFile can.
FPlatformFileManager::Get().GetPlatformFile().SetReadOnly(*DestinationPath, false);
}
++Result.FilesCopied;
if (Options.bRecordWrittenFiles)
{
// Relative to the export root, so the manifest stays valid if the folder is moved or
// renamed - which is the normal fate of an export folder.
FString Relative = DestinationPath;
FPaths::MakePathRelativeTo(Relative, *(Options.TargetDirectory / TEXT("")));
FWrittenFile& Written = Result.WrittenFiles.AddDefaulted_GetRef();
Written.PackageName = PackageName;
Written.RelativePath = MoveTemp(Relative);
}
if (IsExternalExpansion.IsValidIndex(Index) && IsExternalExpansion[Index])
{
++Result.ExternalPackagesCopied;
}
if (FinalFileName != DesiredFileName)
{
++Result.FilesRenamed;
}
else if (bWasPresent)
{
++Result.FilesOverwritten;
}
}
Result.bSuccess = Result.Errors.IsEmpty() && !Result.bCancelled;
UE_LOG(LogAssetUsageAudit, Log, TEXT("Export to '%s': %s"), *Options.TargetDirectory, *Result.Summarise());
for (const FString& Error : Result.Errors)
{
UE_LOG(LogAssetUsageAudit, Warning, TEXT(" %s"), *Error);
}
return Result;
}