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>
This commit is contained in:
2026-09-03 17:39:58 +07:00
parent 1e351cbb9b
commit 243bb1fc55
11 changed files with 2032 additions and 0 deletions
+33
View File
@@ -212,6 +212,39 @@ BOM обязателен: без него Excel ломает кириллицу.
--- ---
## Импорт из внешней папки
`Window → Tools → Import Assets from Folder…` — обратное направление: прочитать папку выгруженных `.uasset` и вернуть выбранное в проект.
Список с галочками, колонки: файл · во что превратится · **откуда взят путь** · статус.
### Откуда берётся целевой путь
Это главная сложность импорта, а не копирование. Ссылка внутри `.uasset` называет цель **полным путём пакета**, поэтому файл заработает только если положить его туда, откуда он пришёл. Ошибка здесь не падает громко — она даёт ассет с отсутствующими ссылками, который обнаружат сильно позже.
Четыре источника по убыванию доверия, и колонка показывает, какой сработал:
| Источник | Надёжность |
|---|---|
| **Manifest** | Мы сами его написали из фактически записанного. Точно |
| **Package header** | `FPackageFileSummary::PackageName` — имя, с которым файл сохраняли. **Замерено на этом проекте: работает** |
| **Folder structure** | Позиция файла под корнем импорта. Верно для зеркальной выгрузки, догадка для прочих |
| **Unresolved** | Не восстановить. Импортируется только в явно названную папку, ссылки не сработают |
⚠️ Про `Package header` есть оговорка **в самом движке** (`AssetHeaderPatcher.cpp:1214`): поле сериализуется не всегда, и движок сам предусматривает откат. Поэтому цепочка, а не одна проверка.
### Что импорт делать откажется
Импорт — единственная операция инструмента, способная **уничтожить работу**: запись поверх `/Game/Art/SM_Rock` заменяет то, что там было, без отмены и без копии.
- Существующий ассет **пропускается**, пока явно не включена перезапись. Перед перезаписью — отдельное подтверждение.
- Пакет, **открытый в редакторе**, не перезаписывается никогда, даже с включённой галочкой: подмена файла под загруженным `UPackage` оставляет сессию с устаревшими объектами, которые потом сохранятся поверх импорта.
- Файлы без восстановимого пути пропускаются, если не названа папка-приёмник.
После копирования выполняется `ScanFilesSynchronous` — без него файлы лежат на диске и невидимы в Content Browser, что читается как «импорт ничего не сделал».
---
## Архитектура ## Архитектура
``` ```
@@ -0,0 +1,332 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetImportScanner.h"
#include "AssetExportManifest.h"
#include "AssetUsageAuditCoreModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Dom/JsonObject.h"
#include "HAL/FileManager.h"
#include "Misc/FileHelper.h"
#include "Misc/PackageName.h"
#include "Misc/Paths.h"
#include "Serialization/Archive.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"
#include "UObject/PackageFileSummary.h"
namespace AssetImportScanner
{
const TCHAR* LexToString(EPackageNameSource Source)
{
switch (Source)
{
case EPackageNameSource::Manifest: return TEXT("Manifest");
case EPackageNameSource::PackageHeader: return TEXT("Package header");
case EPackageNameSource::FolderStructure: return TEXT("Folder structure");
case EPackageNameSource::Unresolved: return TEXT("Unresolved");
}
return TEXT("Unresolved");
}
FString FStats::Summarise() const
{
TArray<FString> Parts;
Parts.Add(FString::Printf(TEXT("%d file(s)"), FilesFound));
if (FromManifest > 0)
{
Parts.Add(FString::Printf(TEXT("%d from the manifest"), FromManifest));
}
if (FromPackageHeader > 0)
{
Parts.Add(FString::Printf(TEXT("%d from package headers"), FromPackageHeader));
}
if (FromFolderStructure > 0)
{
Parts.Add(FString::Printf(TEXT("%d from the folder structure"), FromFolderStructure));
}
if (Unresolved > 0)
{
Parts.Add(FString::Printf(TEXT("%d with no recoverable path"), Unresolved));
}
if (WouldOverwrite > 0)
{
Parts.Add(FString::Printf(TEXT("%d would overwrite an existing asset"), WouldOverwrite));
}
return FString::Join(Parts, TEXT(", "));
}
FString ReadPackageNameFromFile(const FString& FilePath)
{
TUniquePtr<FArchive> Reader(IFileManager::Get().CreateFileReader(*FilePath));
if (!Reader)
{
return FString();
}
FPackageFileSummary Summary;
// Serialising the summary validates the magic number and version itself; a file that is not
// a package leaves the tag unset rather than throwing.
*Reader << Summary;
if (Reader->IsError() || Summary.Tag != PACKAGE_FILE_TAG)
{
return FString();
}
// The engine's own AssetHeaderPatcher treats both of these as "absent" and falls back to the
// file name, so neither is an error worth reporting - just an answer this function cannot
// give for this file.
if (Summary.PackageName.IsEmpty() || Summary.PackageName.Equals(TEXT("None")))
{
return FString();
}
return Summary.PackageName;
}
namespace Private
{
/** Read the manifest, mapping relative file path -> original package name. */
bool LoadManifest(const FString& SourceDirectory, TMap<FString, FName>& OutPackageByRelativePath,
TMap<FName, TArray<FName>>& OutDependenciesByPackage)
{
const FString ManifestPath = FPaths::Combine(SourceDirectory, AssetExportManifest::FileName);
FString Text;
if (!FFileHelper::LoadFileToString(Text, *ManifestPath))
{
return false;
}
TSharedPtr<FJsonObject> Root;
const TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Text);
if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid())
{
UE_LOG(LogAssetUsageAudit, Warning,
TEXT("Found '%s' but could not parse it; falling back to package headers."), *ManifestPath);
return false;
}
const TArray<TSharedPtr<FJsonValue>>* Assets = nullptr;
if (!Root->TryGetArrayField(TEXT("assets"), Assets) || !Assets)
{
return false;
}
for (const TSharedPtr<FJsonValue>& Value : *Assets)
{
const TSharedPtr<FJsonObject> Entry = Value->AsObject();
if (!Entry.IsValid())
{
continue;
}
FString PackageName;
FString RelativeFile;
if (!Entry->TryGetStringField(TEXT("package"), PackageName)
|| !Entry->TryGetStringField(TEXT("file"), RelativeFile))
{
continue;
}
// Normalise so a manifest written on Windows matches a scan using forward slashes.
RelativeFile.ReplaceInline(TEXT("\\"), TEXT("/"));
OutPackageByRelativePath.Add(RelativeFile, FName(*PackageName));
const TArray<TSharedPtr<FJsonValue>>* Dependencies = nullptr;
if (Entry->TryGetArrayField(TEXT("dependencies"), Dependencies) && Dependencies)
{
TArray<FName>& List = OutDependenciesByPackage.FindOrAdd(FName(*PackageName));
for (const TSharedPtr<FJsonValue>& DependencyValue : *Dependencies)
{
const TSharedPtr<FJsonObject> DependencyEntry = DependencyValue->AsObject();
FString DependencyPackage;
if (DependencyEntry.IsValid() && DependencyEntry->TryGetStringField(TEXT("package"), DependencyPackage))
{
List.AddUnique(FName(*DependencyPackage));
}
}
}
}
return true;
}
/**
* Package path implied by a file's position under the import root.
*
* 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)
{
FString Stem = FPaths::Combine(FPaths::GetPath(RelativePath), FPaths::GetBaseFilename(RelativePath));
Stem.ReplaceInline(TEXT("\\"), TEXT("/"));
if (Stem.IsEmpty())
{
return NAME_None;
}
// The mirror layout writes the mount point as a plain folder: /Game/X becomes Game/X.
// Anything else is treated as living under /Game, which is the only mount point an
// import can safely target.
FString PackagePath = Stem.StartsWith(TEXT("Game/"))
? TEXT("/") + Stem
: TEXT("/Game/") + Stem;
return FPackageName::IsValidLongPackageName(PackagePath) ? FName(*PackagePath) : NAME_None;
}
}
TArray<FCandidate> Scan(IAssetRegistry& Registry, const FOptions& Options, FStats& OutStats)
{
OutStats = FStats();
TArray<FCandidate> Candidates;
if (Options.SourceDirectory.IsEmpty() || !IFileManager::Get().DirectoryExists(*Options.SourceDirectory))
{
UE_LOG(LogAssetUsageAudit, Error,
TEXT("Import scan aborted: '%s' is not a folder."), *Options.SourceDirectory);
return Candidates;
}
TMap<FString, FName> PackageByRelativePath;
TMap<FName, TArray<FName>> DependenciesByPackage;
OutStats.bManifestFound = Private::LoadManifest(Options.SourceDirectory, PackageByRelativePath, DependenciesByPackage);
TArray<FString> Files;
IFileManager::Get().FindFilesRecursive(Files, *Options.SourceDirectory, TEXT("*.uasset"), true, false);
TArray<FString> Maps;
IFileManager::Get().FindFilesRecursive(Maps, *Options.SourceDirectory, TEXT("*.umap"), true, false);
Files.Append(MoveTemp(Maps));
Files.Sort();
Candidates.Reserve(Files.Num());
// Everything this folder can supply, so a dependency already travelling with the export is
// not reported missing.
TSet<FName> PackagesInFolder;
PackagesInFolder.Reserve(Files.Num());
for (const FString& AbsoluteFile : Files)
{
FString Relative = AbsoluteFile;
FPaths::MakePathRelativeTo(Relative, *(Options.SourceDirectory / TEXT("")));
Relative.ReplaceInline(TEXT("\\"), TEXT("/"));
FCandidate& Candidate = Candidates.AddDefaulted_GetRef();
Candidate.SourceFile = AbsoluteFile;
Candidate.RelativePath = Relative;
if (const FName* FromManifest = PackageByRelativePath.Find(Relative))
{
Candidate.TargetPackage = *FromManifest;
Candidate.NameSource = EPackageNameSource::Manifest;
++OutStats.FromManifest;
}
else
{
const FString FromHeader = ReadPackageNameFromFile(AbsoluteFile);
if (!FromHeader.IsEmpty() && FPackageName::IsValidLongPackageName(FromHeader))
{
Candidate.TargetPackage = FName(*FromHeader);
Candidate.NameSource = EPackageNameSource::PackageHeader;
++OutStats.FromPackageHeader;
}
else
{
const FName FromFolder = Private::PackageFromFolderStructure(Relative);
if (!FromFolder.IsNone())
{
Candidate.TargetPackage = FromFolder;
Candidate.NameSource = EPackageNameSource::FolderStructure;
++OutStats.FromFolderStructure;
}
else
{
Candidate.NameSource = EPackageNameSource::Unresolved;
++OutStats.Unresolved;
}
}
}
if (!Candidate.TargetPackage.IsNone())
{
PackagesInFolder.Add(Candidate.TargetPackage);
if (const TArray<FName>* Dependencies = DependenciesByPackage.Find(Candidate.TargetPackage))
{
Candidate.Dependencies = *Dependencies;
}
}
++OutStats.FilesFound;
}
// Overwrite and missing-dependency checks need the complete folder contents, so they run
// once everything has a target rather than while the list is still being built.
for (FCandidate& Candidate : Candidates)
{
if (Candidate.TargetPackage.IsNone())
{
continue;
}
TArray<FAssetData> Existing;
Registry.GetAssetsByPackageName(Candidate.TargetPackage, Existing, /*bIncludeOnlyOnDiskAssets=*/true);
Candidate.bTargetExists = !Existing.IsEmpty();
if (Candidate.bTargetExists)
{
++OutStats.WouldOverwrite;
}
for (FName Dependency : Candidate.Dependencies)
{
if (PackagesInFolder.Contains(Dependency))
{
continue;
}
// Already in the project counts as present: importing a mesh into the project it
// came from does not need its textures brought along.
TArray<FAssetData> InProject;
Registry.GetAssetsByPackageName(Dependency, InProject, /*bIncludeOnlyOnDiskAssets=*/true);
if (InProject.IsEmpty())
{
++Candidate.MissingDependencies;
}
}
}
UE_LOG(LogAssetUsageAudit, Log, TEXT("Import scan of '%s': %s%s"),
*Options.SourceDirectory,
*OutStats.Summarise(),
OutStats.bManifestFound ? TEXT("") : TEXT(" (no manifest found)"));
return Candidates;
}
}
@@ -0,0 +1,186 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetImporter.h"
#include "AssetUsageAuditCoreModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "HAL/FileManager.h"
#include "HAL/PlatformFileManager.h"
#include "Misc/PackageName.h"
#include "Misc/Paths.h"
#include "UObject/Package.h"
namespace AssetImporter
{
FString FResult::Summarise() const
{
TArray<FString> Parts;
Parts.Add(FString::Printf(TEXT("%d imported"), FilesImported));
// Each skip reason separately: "12 skipped" tells nobody what to do next, while "12 already
// exist" and "12 are open in the editor" lead to different actions.
if (SkippedExisting > 0)
{
Parts.Add(FString::Printf(TEXT("%d already existed"), SkippedExisting));
}
if (SkippedLoaded > 0)
{
Parts.Add(FString::Printf(TEXT("%d are open in the editor"), SkippedLoaded));
}
if (SkippedUnresolved > 0)
{
Parts.Add(FString::Printf(TEXT("%d had no recoverable path"), SkippedUnresolved));
}
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(", "));
}
namespace Private
{
/** Target package for a candidate, applying the fallback folder. None when it must be skipped. */
FName ResolveTargetPackage(const AssetImportScanner::FCandidate& Candidate, const FOptions& Options)
{
if (!Candidate.TargetPackage.IsNone())
{
return Candidate.TargetPackage;
}
if (Options.UnresolvedDestinationPath.IsEmpty())
{
return NAME_None;
}
// Keep only the file's own name: the source folder structure meant nothing for this
// candidate, which is why the path was unrecoverable in the first place.
const FString AssetName = FPaths::GetBaseFilename(Candidate.SourceFile);
FString Combined = Options.UnresolvedDestinationPath;
if (!Combined.EndsWith(TEXT("/")))
{
Combined += TEXT("/");
}
Combined += AssetName;
return FPackageName::IsValidLongPackageName(Combined) ? FName(*Combined) : NAME_None;
}
}
FResult Import(IAssetRegistry& Registry, const TArray<AssetImportScanner::FCandidate>& Candidates, const FOptions& Options)
{
FResult Result;
IFileManager& FileManager = IFileManager::Get();
const int32 Total = Candidates.Num();
for (int32 Index = 0; Index < Total; ++Index)
{
if (Options.OnProgress && !Options.OnProgress(Index, Total))
{
Result.bCancelled = true;
break;
}
const AssetImportScanner::FCandidate& Candidate = Candidates[Index];
const FName TargetPackage = Private::ResolveTargetPackage(Candidate, Options);
if (TargetPackage.IsNone())
{
++Result.SkippedUnresolved;
continue;
}
// Refuse before touching the disk if the editor is holding this package. Replacing the
// file underneath a loaded UPackage leaves the session with stale objects that get
// saved back over the import - a corruption that surfaces much later than the import.
if (FindPackage(nullptr, *TargetPackage.ToString()))
{
++Result.SkippedLoaded;
UE_LOG(LogAssetUsageAudit, Warning,
TEXT("Not importing '%s': the package is loaded in this editor session. Close the asset and re-run."),
*TargetPackage.ToString());
continue;
}
// The extension has to match what the package actually is: a level saved as .uasset
// will not be found by the engine, which looks for maps by their own extension.
const bool bIsMap = Candidate.SourceFile.EndsWith(FPackageName::GetMapPackageExtension());
FString DestinationPath;
if (!FPackageName::TryConvertLongPackageNameToFilename(
TargetPackage.ToString(),
DestinationPath,
bIsMap ? FPackageName::GetMapPackageExtension() : FPackageName::GetAssetPackageExtension()))
{
Result.Errors.Add(FString::Printf(
TEXT("'%s' does not map to a file path in this project."), *TargetPackage.ToString()));
continue;
}
const bool bTargetOnDisk = FileManager.FileExists(*DestinationPath);
if (bTargetOnDisk && !Options.bOverwriteExisting)
{
++Result.SkippedExisting;
continue;
}
const FString DestinationDir = FPaths::GetPath(DestinationPath);
if (!FileManager.DirectoryExists(*DestinationDir) && !FileManager.MakeDirectory(*DestinationDir, true))
{
Result.Errors.Add(FString::Printf(TEXT("Could not create '%s'."), *DestinationDir));
continue;
}
// 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))
{
FPlatformFileManager::Get().GetPlatformFile().SetReadOnly(*DestinationPath, false);
}
if (FileManager.Copy(*DestinationPath, *Candidate.SourceFile, /*bReplace=*/true) != COPY_OK)
{
Result.Errors.Add(FString::Printf(
TEXT("Failed to copy '%s' to '%s'."), *Candidate.SourceFile, *DestinationPath));
continue;
}
++Result.FilesImported;
Result.ImportedFiles.Add(DestinationPath);
}
// Without this the files sit on disk and the Content Browser shows nothing, which reads as
// an import that silently did nothing.
if (!Result.ImportedFiles.IsEmpty())
{
Registry.ScanFilesSynchronous(Result.ImportedFiles, /*bForceRescan=*/true);
}
Result.bSuccess = Result.Errors.IsEmpty() && !Result.bCancelled;
UE_LOG(LogAssetUsageAudit, Log, TEXT("Import: %s"), *Result.Summarise());
for (const FString& Error : Result.Errors)
{
UE_LOG(LogAssetUsageAudit, Warning, TEXT(" %s"), *Error);
}
return Result;
}
}
@@ -0,0 +1,120 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
class IAssetRegistry;
/**
* Reads a folder of exported .uasset files and works out where each one would have to go.
*
* The hard part is not copying files, it is knowing the package path to copy them to. A reference
* inside a .uasset names its target by full package path, so an imported asset only resolves when
* it is restored to the path it came from. Getting that path wrong does not fail loudly - it
* produces an asset that loads with missing references, which is discovered much later.
*
* Four sources are tried in order, and the one used is reported per file so the UI can show it:
*
* 1. Our own manifest. Authoritative: we wrote it from what the exporter actually wrote.
* 2. The package header. FPackageFileSummary carries "the package name the file was last saved
* with" - but the engine's own AssetHeaderPatcher notes it is not always serialised and falls
* back when it is empty or "None", so this cannot be trusted blindly either.
* 3. The folder structure under the import root, which is exactly right for a MirrorTree export
* and a guess for anything else.
* 4. Nothing. The file can still be imported, but only into a folder the user names, and its
* references will not resolve. Said plainly rather than papered over.
*/
namespace AssetImportScanner
{
/** Where a candidate's target package path came from. Shown per row; not an implementation detail. */
enum class EPackageNameSource : uint8
{
/** From AssetUsageAudit.manifest.json written beside the files. */
Manifest,
/** From FPackageFileSummary::PackageName inside the .uasset. */
PackageHeader,
/** Inferred from the file's position under the import root. Correct for a mirrored export. */
FolderStructure,
/** Not recoverable. Importing this file cannot restore its references. */
Unresolved
};
ASSETUSAGEAUDITCORE_API const TCHAR* LexToString(EPackageNameSource Source);
struct FCandidate
{
/** Absolute path of the file in the source folder. */
FString SourceFile;
/** Path relative to the import root, for display. */
FString RelativePath;
/** Package it would be restored to. None when Unresolved. */
FName TargetPackage;
EPackageNameSource NameSource = EPackageNameSource::Unresolved;
/**
* A package of that name already exists in this project.
*
* Never imported silently: overwriting is how an import tool destroys someone's work, and
* the file on disk gives no hint that it was about to happen.
*/
bool bTargetExists = false;
/** Packages this asset referenced at export time. Only known from a manifest. */
TArray<FName> Dependencies;
/**
* Dependencies that are neither in this folder nor already in the project.
*
* A non-zero count means the asset will import and then open with something missing.
*/
int32 MissingDependencies = 0;
};
struct FOptions
{
/** Folder to read. Scanned recursively. */
FString SourceDirectory;
};
struct ASSETUSAGEAUDITCORE_API FStats
{
bool bManifestFound = false;
int32 FilesFound = 0;
int32 FromManifest = 0;
int32 FromPackageHeader = 0;
int32 FromFolderStructure = 0;
int32 Unresolved = 0;
/** Candidates whose target already exists in the project. */
int32 WouldOverwrite = 0;
FString Summarise() const;
};
/**
* Scan a folder for importable packages.
*
* Reads only package headers, never loads an asset: a folder can hold gigabytes, and the list
* has to appear immediately for the user to tick through.
*/
ASSETUSAGEAUDITCORE_API TArray<FCandidate> Scan(
IAssetRegistry& Registry,
const FOptions& Options,
FStats& OutStats);
/**
* The package name a .uasset was saved with, or empty when the file does not carry one.
*
* Empty is a normal answer, not an error: the field is not always serialised. Callers must have
* a fallback rather than treating an empty result as a corrupt file.
*/
ASSETUSAGEAUDITCORE_API FString ReadPackageNameFromFile(const FString& FilePath);
}
@@ -0,0 +1,87 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "AssetImportScanner.h"
class IAssetRegistry;
/**
* Copies scanned files into the project at their recovered package paths.
*
* A byte copy is enough: a .uasset is self-contained and its references are resolved by package
* path, so putting the file where the path says produces a working asset without loading anything.
* That is also why the scanner's job - deciding the path - is the hard half and this is the easy one.
*
* What this refuses to do matters more than what it does. Importing is the one operation here that
* can destroy work: writing over /Game/Art/SM_Rock replaces whatever the project had under that
* name, with no undo and no trace. So an existing target is skipped unless overwriting was asked
* for explicitly, and a package already loaded in the editor is never written over at all.
*/
namespace AssetImporter
{
struct FOptions
{
/**
* Replace assets that already exist at the target path.
*
* Off by default and deliberately awkward to turn on: the scanner reports the count up
* front so the choice is made knowingly rather than discovered afterwards.
*/
bool bOverwriteExisting = false;
/**
* Where to put files whose package path could not be recovered, e.g. "/Game/Imported".
*
* Empty means skip them. They can be imported, but their references will not resolve, so
* putting them somewhere by default would quietly fill the project with broken assets.
*/
FString UnresolvedDestinationPath;
/** Called as (Done, Total). Return false to stop; files already written are kept. */
TFunction<bool(int32, int32)> OnProgress;
};
struct ASSETUSAGEAUDITCORE_API FResult
{
bool bSuccess = false;
int32 FilesImported = 0;
/** Target existed and overwriting was not requested. */
int32 SkippedExisting = 0;
/** No recoverable package path and no fallback folder was given. */
int32 SkippedUnresolved = 0;
/**
* Target package is loaded in this editor session.
*
* Counted separately because it is not a user choice to make: replacing the file under a
* loaded package leaves the editor holding stale objects that will be saved back over the
* new file. The asset has to be closed first.
*/
int32 SkippedLoaded = 0;
bool bCancelled = false;
TArray<FString> Errors;
/** Absolute paths written, for the Asset Registry rescan that makes them visible. */
TArray<FString> ImportedFiles;
FString Summarise() const;
};
/**
* Import the given candidates.
*
* Only candidates the caller wants should be passed in - this applies no filtering of its own
* beyond the safety rules above.
*/
ASSETUSAGEAUDITCORE_API FResult Import(
IAssetRegistry& Registry,
const TArray<AssetImportScanner::FCandidate>& Candidates,
const FOptions& Options);
}
@@ -5,6 +5,7 @@
#include "AssetUsageAuditSettings.h" #include "AssetUsageAuditSettings.h"
#include "AssetUsageAuditor.h" #include "AssetUsageAuditor.h"
#include "AssetUsageReportWriter.h" #include "AssetUsageReportWriter.h"
#include "SAssetImportDialog.h"
#include "SAssetUsageAuditPanel.h" #include "SAssetUsageAuditPanel.h"
#include "AssetRegistry/AssetRegistryModule.h" #include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h" #include "AssetRegistry/IAssetRegistry.h"
@@ -90,6 +91,18 @@ void FAssetUsageAuditEditorModule::RegisterMenus()
{ {
FGlobalTabmanager::Get()->TryInvokeTab(FAssetUsageAuditEditorModule::PanelTabId); FGlobalTabmanager::Get()->TryInvokeTab(FAssetUsageAuditEditorModule::PanelTabId);
}))); })));
// Import sits beside the audit rather than inside its panel: it is the reverse direction and
// needs no audit result, so requiring a sweep first would be pure ceremony.
Section.AddMenuEntry(
TEXT("ImportAssetsFromFolder"),
LOCTEXT("OpenImport", "Import Assets from Folder..."),
LOCTEXT("OpenImportTip", "Read a folder of exported .uasset files and bring the chosen ones back into this project."),
FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.Import"),
FUIAction(FExecuteAction::CreateLambda([]()
{
ShowAssetImportDialog();
})));
} }
void FAssetUsageAuditEditorModule::RegisterConsoleCommands() void FAssetUsageAuditEditorModule::RegisterConsoleCommands()
@@ -0,0 +1,619 @@
// NextGenium 2026. Asset Usage Audit.
#include "SAssetImportDialog.h"
#include "AssetImportScanner.h"
#include "AssetImporter.h"
#include "AssetUsageAuditEditorModule.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "DesktopPlatformModule.h"
#include "Framework/Application/SlateApplication.h"
#include "Framework/Docking/TabManager.h"
#include "IDesktopPlatform.h"
#include "Misc/MessageDialog.h"
#include "Misc/Paths.h"
#include "Misc/ScopedSlowTask.h"
#include "Styling/AppStyle.h"
#include "Widgets/Input/SButton.h"
#include "Widgets/Input/SCheckBox.h"
#include "Widgets/Input/SEditableTextBox.h"
#include "Widgets/Layout/SBorder.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 "AssetImportDialog"
namespace ImportColumns
{
static const FName Check("Check");
static const FName Name("Name");
static const FName Target("Target");
static const FName Source("Source");
static const FName Status("Status");
}
using FImportRowPtr = TSharedPtr<AssetImportScanner::FCandidate>;
namespace
{
/** Colour the provenance so the untrustworthy rows stand out without being read. */
FSlateColor SourceColour(AssetImportScanner::EPackageNameSource Source)
{
switch (Source)
{
case AssetImportScanner::EPackageNameSource::Manifest:
return FSlateColor(FLinearColor(0.35f, 0.78f, 0.35f));
case AssetImportScanner::EPackageNameSource::PackageHeader:
return FSlateColor(FLinearColor(0.55f, 0.72f, 0.90f));
case AssetImportScanner::EPackageNameSource::FolderStructure:
return FSlateColor(FLinearColor(0.85f, 0.75f, 0.35f));
case AssetImportScanner::EPackageNameSource::Unresolved:
return FSlateColor(FLinearColor(0.90f, 0.45f, 0.40f));
}
return FSlateColor::UseForeground();
}
FText SourceTooltip(AssetImportScanner::EPackageNameSource Source)
{
switch (Source)
{
case AssetImportScanner::EPackageNameSource::Manifest:
return LOCTEXT("SrcManifest", "From the manifest written alongside the export. Authoritative.");
case AssetImportScanner::EPackageNameSource::PackageHeader:
return LOCTEXT("SrcHeader", "From the package name stored inside the .uasset. Reliable when present, but the engine does not always write it.");
case AssetImportScanner::EPackageNameSource::FolderStructure:
return LOCTEXT("SrcFolder", "Guessed from where the file sits in the folder. Correct for an export that mirrored the content tree; a guess otherwise.");
case AssetImportScanner::EPackageNameSource::Unresolved:
return LOCTEXT("SrcNone", "The original path could not be recovered. Importing this file will not restore its references unless you name a destination folder below.");
}
return FText::GetEmpty();
}
}
/** One scanned file. */
class SImportRowWidget : public SMultiColumnTableRow<FImportRowPtr>
{
public:
SLATE_BEGIN_ARGS(SImportRowWidget) {}
SLATE_ARGUMENT(FImportRowPtr, Item)
SLATE_EVENT(FOnCheckStateChanged, OnCheckChanged)
SLATE_ATTRIBUTE(ECheckBoxState, CheckState)
SLATE_END_ARGS()
void Construct(const FArguments& InArgs, const TSharedRef<STableViewBase>& InOwnerTable)
{
Item = InArgs._Item;
OnCheckChanged = InArgs._OnCheckChanged;
CheckState = InArgs._CheckState;
SMultiColumnTableRow<FImportRowPtr>::Construct(FSuperRowType::FArguments(), InOwnerTable);
}
virtual TSharedRef<SWidget> GenerateWidgetForColumn(const FName& ColumnName) override
{
if (!Item.IsValid())
{
return SNullWidget::NullWidget;
}
const auto TextCell = [](const FString& Text, const FSlateColor& Colour = FSlateColor::UseForeground(), const FText& Tooltip = FText::GetEmpty())
{
return SNew(SBox)
.VAlign(VAlign_Center)
.Padding(FMargin(4.0f, 0.0f))
[
SNew(STextBlock)
.Text(FText::FromString(Text))
.ColorAndOpacity(Colour)
.ToolTipText(Tooltip.IsEmpty() ? FText::FromString(Text) : Tooltip)
];
};
if (ColumnName == ImportColumns::Check)
{
return SNew(SBox)
.HAlign(HAlign_Center)
.VAlign(VAlign_Center)
[
SNew(SCheckBox)
.IsChecked(CheckState)
.OnCheckStateChanged(OnCheckChanged)
];
}
if (ColumnName == ImportColumns::Name)
{
return TextCell(Item->RelativePath);
}
if (ColumnName == ImportColumns::Target)
{
return Item->TargetPackage.IsNone()
? TextCell(TEXT("-"), FSlateColor::UseSubduedForeground(),
LOCTEXT("NoTarget", "No package path could be recovered for this file."))
: TextCell(Item->TargetPackage.ToString());
}
if (ColumnName == ImportColumns::Source)
{
return TextCell(
AssetImportScanner::LexToString(Item->NameSource),
SourceColour(Item->NameSource),
SourceTooltip(Item->NameSource));
}
if (ColumnName == ImportColumns::Status)
{
TArray<FString> Notes;
if (Item->bTargetExists)
{
Notes.Add(TEXT("already in project"));
}
if (Item->MissingDependencies > 0)
{
Notes.Add(FString::Printf(TEXT("%d missing dependencies"), Item->MissingDependencies));
}
if (Notes.IsEmpty())
{
return TextCell(TEXT("new"), FSlateColor(FLinearColor(0.35f, 0.78f, 0.35f)));
}
return TextCell(
FString::Join(Notes, TEXT(", ")),
FSlateColor(FLinearColor(0.85f, 0.75f, 0.35f)),
LOCTEXT("StatusTip", "\"Already in project\" means importing would replace an existing asset - only possible with the overwrite option below. Missing dependencies import fine but open with something absent."));
}
return SNullWidget::NullWidget;
}
private:
FImportRowPtr Item;
FOnCheckStateChanged OnCheckChanged;
TAttribute<ECheckBoxState> CheckState;
};
/** The import window body. */
class SAssetImportDialog : public SCompoundWidget
{
public:
SLATE_BEGIN_ARGS(SAssetImportDialog) {}
SLATE_ARGUMENT(TSharedPtr<SWindow>, ParentWindow)
SLATE_END_ARGS()
void Construct(const FArguments& InArgs)
{
ParentWindow = InArgs._ParentWindow;
StatusText = LOCTEXT("PickFolder", "Choose a folder of exported assets, then press Scan.");
HeaderRow = SNew(SHeaderRow)
+ SHeaderRow::Column(ImportColumns::Check)
.DefaultLabel(FText::GetEmpty())
.FixedWidth(28.0f)
.HAlignHeader(HAlign_Center)
[
SNew(SCheckBox)
.IsChecked(this, &SAssetImportDialog::GetHeaderCheckState)
.OnCheckStateChanged(this, &SAssetImportDialog::OnHeaderCheckChanged)
.ToolTipText(LOCTEXT("TickAll", "Tick or clear every importable row. Rows with no recoverable path stay untouched unless a destination folder is given."))
]
+ SHeaderRow::Column(ImportColumns::Name).DefaultLabel(LOCTEXT("ColFile", "File")).FillWidth(0.30f)
+ SHeaderRow::Column(ImportColumns::Target).DefaultLabel(LOCTEXT("ColTarget", "Will become")).FillWidth(0.35f)
+ SHeaderRow::Column(ImportColumns::Source).DefaultLabel(LOCTEXT("ColSource", "Path from")).FillWidth(0.15f)
+ SHeaderRow::Column(ImportColumns::Status).DefaultLabel(LOCTEXT("ColStatus", "Status")).FillWidth(0.20f);
ChildSlot
[
SNew(SBorder)
.BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder"))
.Padding(10.0f)
[
SNew(SVerticalBox)
// --- Source folder -------------------------------------------------------------
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 6.0f)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
[
SNew(STextBlock).Text(LOCTEXT("Folder", "Folder"))
]
+ SHorizontalBox::Slot()
.FillWidth(1.0f)
.Padding(8.0f, 0.0f)
[
SNew(SEditableTextBox)
.Text_Lambda([this]() { return FText::FromString(SourceDirectory); })
.OnTextCommitted_Lambda([this](const FText& NewText, ETextCommit::Type)
{
SourceDirectory = NewText.ToString();
})
.HintText(LOCTEXT("FolderHint", "Folder containing exported .uasset files"))
]
+ SHorizontalBox::Slot()
.AutoWidth()
[
SNew(SButton)
.Text(LOCTEXT("Browse", "Browse..."))
.OnClicked(this, &SAssetImportDialog::OnBrowse)
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
[
SNew(SButton)
.Text(LOCTEXT("Scan", "Scan"))
.IsEnabled_Lambda([this]() { return !SourceDirectory.IsEmpty(); })
.OnClicked(this, &SAssetImportDialog::OnScan)
]
]
+ SVerticalBox::Slot().AutoHeight()[ SNew(SSeparator) ]
// --- List ----------------------------------------------------------------------
+ SVerticalBox::Slot()
.FillHeight(1.0f)
.Padding(0.0f, 6.0f)
[
SAssignNew(ListView, SListView<FImportRowPtr>)
.ListItemsSource(&Rows)
.OnGenerateRow(this, &SAssetImportDialog::OnGenerateRow)
.SelectionMode(ESelectionMode::None)
.HeaderRow(HeaderRow)
]
// --- Options -------------------------------------------------------------------
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 6.0f)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
[
SNew(SCheckBox)
.IsChecked_Lambda([this]()
{
return bOverwriteExisting ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
})
.OnCheckStateChanged_Lambda([this](ECheckBoxState NewState)
{
bOverwriteExisting = (NewState == ECheckBoxState::Checked);
})
.ToolTipText(LOCTEXT("OverwriteTip", "Replace assets that already exist at the target path. This cannot be undone and there is no copy of what was there before. Off unless you mean it."))
[
SNew(STextBlock)
.Text(LOCTEXT("Overwrite", "Overwrite existing assets"))
.ColorAndOpacity_Lambda([this]()
{
return bOverwriteExisting
? FSlateColor(FLinearColor(0.90f, 0.45f, 0.40f))
: FSlateColor::UseForeground();
})
]
]
+ SHorizontalBox::Slot()
.FillWidth(1.0f)
.VAlign(VAlign_Center)
.Padding(16.0f, 0.0f, 0.0f, 0.0f)
[
SNew(SEditableTextBox)
.Text_Lambda([this]() { return FText::FromString(UnresolvedDestination); })
.OnTextCommitted_Lambda([this](const FText& NewText, ETextCommit::Type)
{
UnresolvedDestination = NewText.ToString();
})
.HintText(LOCTEXT("UnresolvedHint", "Folder for files with no recoverable path, e.g. /Game/Imported"))
.ToolTipText(LOCTEXT("UnresolvedTip", "Files whose original package path could not be recovered are skipped unless you name a folder here. They will import, but their references will not resolve."))
]
]
// --- Footer --------------------------------------------------------------------
+ SVerticalBox::Slot()
.AutoHeight()
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.FillWidth(1.0f)
.VAlign(VAlign_Center)
[
SNew(STextBlock)
.AutoWrapText(true)
.Text_Lambda([this]() { return StatusText; })
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(8.0f, 0.0f, 0.0f, 0.0f)
[
SNew(SButton)
.Text_Lambda([this]()
{
return FText::Format(LOCTEXT("ImportN", "Import {0}"), FText::AsNumber(CheckedFiles.Num()));
})
.IsEnabled_Lambda([this]() { return CheckedFiles.Num() > 0; })
.OnClicked(this, &SAssetImportDialog::OnImport)
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
[
SNew(SButton)
.Text(LOCTEXT("Close", "Close"))
.OnClicked(this, &SAssetImportDialog::OnClose)
]
]
]
];
}
private:
TSharedRef<ITableRow> OnGenerateRow(FImportRowPtr Item, const TSharedRef<STableViewBase>& OwnerTable)
{
return SNew(SImportRowWidget, OwnerTable)
.Item(Item)
.CheckState(TAttribute<ECheckBoxState>::CreateLambda([this, Item]()
{
return Item.IsValid() && CheckedFiles.Contains(Item->SourceFile)
? ECheckBoxState::Checked
: ECheckBoxState::Unchecked;
}))
.OnCheckChanged(FOnCheckStateChanged::CreateLambda([this, Item](ECheckBoxState NewState)
{
if (!Item.IsValid())
{
return;
}
if (NewState == ECheckBoxState::Checked)
{
CheckedFiles.Add(Item->SourceFile);
}
else
{
CheckedFiles.Remove(Item->SourceFile);
}
}));
}
ECheckBoxState GetHeaderCheckState() const
{
if (CheckedFiles.IsEmpty())
{
return ECheckBoxState::Unchecked;
}
return CheckedFiles.Num() == Rows.Num() ? ECheckBoxState::Checked : ECheckBoxState::Undetermined;
}
void OnHeaderCheckChanged(ECheckBoxState NewState)
{
CheckedFiles.Reset();
if (NewState == ECheckBoxState::Checked)
{
for (const FImportRowPtr& Row : Rows)
{
if (Row.IsValid())
{
CheckedFiles.Add(Row->SourceFile);
}
}
}
}
FReply OnBrowse()
{
IDesktopPlatform* DesktopPlatform = FDesktopPlatformModule::Get();
if (!DesktopPlatform)
{
StatusText = LOCTEXT("NoPicker", "Could not open a folder picker (DesktopPlatform is unavailable).");
UE_LOG(LogAssetUsageAuditEditor, Error, TEXT("Import dialog: DesktopPlatform unavailable."));
return FReply::Handled();
}
const void* ParentHandle = ParentWindow.IsValid() && ParentWindow->GetNativeWindow().IsValid()
? ParentWindow->GetNativeWindow()->GetOSWindowHandle()
: nullptr;
FString Chosen;
if (DesktopPlatform->OpenDirectoryDialog(ParentHandle,
LOCTEXT("PickImportDir", "Choose a folder of exported assets").ToString(),
SourceDirectory, Chosen))
{
SourceDirectory = Chosen;
}
return FReply::Handled();
}
FReply OnScan()
{
Rows.Reset();
CheckedFiles.Reset();
IAssetRegistry& Registry =
FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry")).Get();
if (Registry.IsLoadingAssets())
{
// Without this the "already in project" column is answered against a partial registry,
// which would invite someone to overwrite an asset the scan simply had not seen yet.
StatusText = LOCTEXT("WaitingRegistry", "Waiting for the Asset Registry to finish scanning...");
Registry.WaitForCompletion();
}
AssetImportScanner::FOptions Options;
Options.SourceDirectory = SourceDirectory;
AssetImportScanner::FStats Stats;
const TArray<AssetImportScanner::FCandidate> Candidates =
AssetImportScanner::Scan(Registry, Options, Stats);
Rows.Reserve(Candidates.Num());
for (const AssetImportScanner::FCandidate& Candidate : Candidates)
{
Rows.Add(MakeShared<AssetImportScanner::FCandidate>(Candidate));
}
if (ListView.IsValid())
{
ListView->RequestListRefresh();
}
StatusText = Rows.IsEmpty()
? LOCTEXT("NothingFound", "No .uasset or .umap files were found in that folder.")
: FText::Format(
LOCTEXT("ScanDone", "{0}{1}"),
FText::FromString(Stats.Summarise()),
Stats.bManifestFound
? LOCTEXT("WithManifest", ". A manifest was found, so the original paths are exact.")
: LOCTEXT("NoManifest", ". No manifest here - paths come from the files themselves, so check the \"Path from\" column."));
return FReply::Handled();
}
FReply OnImport()
{
TArray<AssetImportScanner::FCandidate> Selected;
Selected.Reserve(CheckedFiles.Num());
int32 WouldOverwrite = 0;
for (const FImportRowPtr& Row : Rows)
{
if (!Row.IsValid() || !CheckedFiles.Contains(Row->SourceFile))
{
continue;
}
Selected.Add(*Row);
if (Row->bTargetExists)
{
++WouldOverwrite;
}
}
if (Selected.IsEmpty())
{
return FReply::Handled();
}
// The one destructive path in this whole tool. Named assets, not just a count, because
// "12 assets will be replaced" is not something anyone can check.
if (WouldOverwrite > 0 && bOverwriteExisting)
{
const EAppReturnType::Type Answer = FMessageDialog::Open(EAppMsgType::YesNo, FText::Format(
LOCTEXT("ConfirmOverwrite",
"Replace {0} asset(s) that already exist in this project?\n\n"
"The existing files are overwritten in place. There is no undo, and no copy is kept.\n\n"
"Anything currently open in the editor is skipped rather than replaced."),
FText::AsNumber(WouldOverwrite)));
if (Answer != EAppReturnType::Yes)
{
UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Import cancelled at the overwrite confirmation."));
return FReply::Handled();
}
}
AssetImporter::FOptions Options;
Options.bOverwriteExisting = bOverwriteExisting;
Options.UnresolvedDestinationPath = UnresolvedDestination;
FScopedSlowTask SlowTask(static_cast<float>(Selected.Num()), LOCTEXT("Importing", "Importing assets..."));
SlowTask.MakeDialog(true);
Options.OnProgress = [&SlowTask](int32, int32)
{
SlowTask.EnterProgressFrame(1.0f);
return !SlowTask.ShouldCancel();
};
IAssetRegistry& Registry =
FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry")).Get();
const AssetImporter::FResult Result = AssetImporter::Import(Registry, Selected, Options);
StatusText = FText::FromString(Result.Summarise());
if (!Result.Errors.IsEmpty())
{
FMessageDialog::Open(EAppMsgType::Ok, FText::Format(
LOCTEXT("ImportFailures", "{0} file(s) could not be imported. See the Output Log for details."),
FText::AsNumber(Result.Errors.Num())));
}
// Re-scan so the list reflects what the project now holds: rows just imported become
// "already in project", which is the truth and stops a second click doing it again.
return OnScan();
}
FReply OnClose()
{
if (ParentWindow.IsValid())
{
ParentWindow->RequestDestroyWindow();
}
return FReply::Handled();
}
FString SourceDirectory;
FString UnresolvedDestination;
bool bOverwriteExisting = false;
TArray<FImportRowPtr> Rows;
/** Ticked rows, keyed by source file so ticks survive a re-scan. */
TSet<FString> CheckedFiles;
FText StatusText;
TSharedPtr<SListView<FImportRowPtr>> ListView;
TSharedPtr<SHeaderRow> HeaderRow;
TSharedPtr<SWindow> ParentWindow;
};
void ShowAssetImportDialog()
{
TSharedRef<SWindow> Window = SNew(SWindow)
.Title(LOCTEXT("WindowTitle", "Import assets from a folder"))
.ClientSize(FVector2D(900.0f, 600.0f))
.SupportsMinimize(false)
.SupportsMaximize(true);
Window->SetContent(SNew(SAssetImportDialog).ParentWindow(Window));
FSlateApplication::Get().AddModalWindow(Window, FGlobalTabmanager::Get()->GetRootWindow());
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,16 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
/**
* Show the import window: pick a folder, review what it holds, tick what to bring in.
*
* Modal, like the export window and for the same reason - the list describes a folder on disk at a
* moment in time, and letting the project change underneath it would make the "already exists" and
* "missing dependency" columns lie.
*
* Blocks until the window closes. Nothing is written unless Import is pressed.
*/
void ShowAssetImportDialog();
@@ -19,6 +19,11 @@ public class AssetUsageAuditTests : ModuleRules
{ {
"AssetRegistry", "AssetRegistry",
"AssetUsageAuditCore", "AssetUsageAuditCore",
// Only the Migrate live spec needs these, and only ever under EditorContext. The Core
// module still must not link them - that invariant is about the analysis being runnable
// headless, and a test module is not part of that path.
"AssetTools",
"UnrealEd",
"Json", "Json",
"Projects" "Projects"
}); });
@@ -0,0 +1,385 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetImportScanner.h"
#include "AssetExportManifest.h"
#include "AssetImporter.h"
#include "AssetRegistry/ARFilter.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "AssetUsageAuditTypes.h"
#include "AssetUsageExporter.h"
#include "Engine/World.h"
#include "HAL/FileManager.h"
#include "Misc/AutomationTest.h"
#include "Misc/PackageName.h"
#include "Misc/Paths.h"
#if WITH_DEV_AUTOMATION_TESTS
/**
* Export, scan, import - the loop the manifest exists to close.
*
* The export side is covered elsewhere; what is unproven until here is that the folder an export
* leaves behind can actually be read back. A manifest that cannot be consumed is decoration, and
* nothing in the writer's own specs would notice.
*/
BEGIN_DEFINE_SPEC(AssetImportRoundTripSpec,
"AssetUsageAudit.ImportRoundTrip",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
FString ScratchDir;
/**
* A package path this project cannot already contain.
*
* The importer writes into the running project's Content folder - that is its whole job - so
* every spec that lets it write must aim somewhere no real asset lives, and clean up after.
*/
static const TCHAR* ScratchPackageRoot() { return TEXT("/Game/AssetUsageAuditImportScratch"); }
static FString ScratchContentDir()
{
return FPaths::Combine(FPaths::ProjectContentDir(), TEXT("AssetUsageAuditImportScratch"));
}
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 CleanUp()
{
if (!ScratchDir.IsEmpty())
{
IFileManager::Get().DeleteDirectory(*ScratchDir, false, true);
}
// Always, even when the spec did not import: a half-finished run must not leave a stray
// folder inside the project's content.
IFileManager::Get().DeleteDirectory(*ScratchContentDir(), false, true);
}
static IAssetRegistry& ReadyRegistry()
{
IAssetRegistry& Registry = IAssetRegistry::GetChecked();
if (Registry.IsLoadingAssets())
{
Registry.WaitForCompletion();
}
return Registry;
}
/** An ordinary /Game asset with at least one /Game dependency, chosen deterministically. */
static FName FindAssetWithGameDependency(FName& OutDependency)
{
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 (AssetData.PackageName.ToString().Contains(TEXT("__External")))
{
return true;
}
Candidates.Add(AssetData.PackageName);
return Candidates.Num() < 2048;
});
Candidates.Sort(FNameLexicalLess());
TArray<FName> Dependencies;
for (FName Candidate : Candidates)
{
Dependencies.Reset();
Registry.GetDependencies(Candidate, Dependencies,
AssetUsageAudit::MakeTraversalCategory(), AssetUsageAudit::MakeTraversalQuery());
for (FName Dependency : Dependencies)
{
const FString AsString = Dependency.ToString();
if (AsString.StartsWith(TEXT("/Game/")) && !AsString.Contains(TEXT("__External")))
{
OutDependency = Dependency;
return Candidate;
}
}
}
return NAME_None;
}
END_DEFINE_SPEC(AssetImportRoundTripSpec)
void AssetImportRoundTripSpec::Define()
{
Describe("Reading back an export", [this]()
{
AfterEach([this]() { CleanUp(); });
It("recovers every package path from the manifest", [this]()
{
FName Dependency = NAME_None;
const FName Package = FindAssetWithGameDependency(Dependency);
if (Package.IsNone())
{
AddWarning(TEXT("No /Game asset with a /Game dependency was found; skipping."));
return;
}
MakeScratchDir(TEXT("RoundTripManifest"));
// Flat on purpose: the layout that destroys the reference graph is the one the manifest
// has to compensate for, so it is the only interesting case to read back.
FAssetUsageExporter::FOptions ExportOptions;
ExportOptions.TargetDirectory = ScratchDir;
ExportOptions.bIncludeExternalPackages = false;
ExportOptions.bRecordWrittenFiles = true;
const FAssetUsageExporter::FResult ExportResult =
FAssetUsageExporter::ExportPackageFiles({ Package, Dependency }, ExportOptions);
TestEqual(TEXT("two files exported"), ExportResult.FilesCopied, 2);
AssetExportManifest::FOptions ManifestOptions;
ManifestOptions.TargetDirectory = ScratchDir;
ManifestOptions.SeedPackages = { Package };
const AssetExportManifest::FResult ManifestResult =
AssetExportManifest::Write(ReadyRegistry(), ExportResult.WrittenFiles, ManifestOptions);
TestTrue(TEXT("manifest written"), ManifestResult.bSuccess);
AssetImportScanner::FOptions ScanOptions;
ScanOptions.SourceDirectory = ScratchDir;
AssetImportScanner::FStats ScanStats;
const TArray<AssetImportScanner::FCandidate> Candidates =
AssetImportScanner::Scan(ReadyRegistry(), ScanOptions, ScanStats);
TestTrue(TEXT("manifest was found"), ScanStats.bManifestFound);
TestEqual(TEXT("both files scanned"), ScanStats.FilesFound, 2);
TestEqual(TEXT("both paths came from the manifest"), ScanStats.FromManifest, 2);
TestEqual(TEXT("nothing unresolved"), ScanStats.Unresolved, 0);
// They came from this project, so re-importing them would overwrite the originals.
// The scanner has to say so before anyone ticks a box.
TestEqual(TEXT("both flagged as already present"), ScanStats.WouldOverwrite, 2);
bool bFoundOriginal = false;
for (const AssetImportScanner::FCandidate& Candidate : Candidates)
{
if (Candidate.TargetPackage == Package)
{
bFoundOriginal = true;
TestEqual(TEXT("source is the manifest"),
Candidate.NameSource, AssetImportScanner::EPackageNameSource::Manifest);
}
}
TestTrue(TEXT("the exported asset is in the scan"), bFoundOriginal);
});
It("falls back to the package header when the manifest is missing", [this]()
{
// The manifest is an optional courtesy; a folder someone assembled by hand has none.
// This is what makes the tool useful on an export it did not produce.
FName Dependency = NAME_None;
const FName Package = FindAssetWithGameDependency(Dependency);
if (Package.IsNone())
{
AddWarning(TEXT("No suitable asset was found; skipping."));
return;
}
MakeScratchDir(TEXT("RoundTripNoManifest"));
FAssetUsageExporter::FOptions ExportOptions;
ExportOptions.TargetDirectory = ScratchDir;
ExportOptions.bIncludeExternalPackages = false;
FAssetUsageExporter::ExportPackageFiles({ Package }, ExportOptions);
AssetImportScanner::FOptions ScanOptions;
ScanOptions.SourceDirectory = ScratchDir;
AssetImportScanner::FStats ScanStats;
const TArray<AssetImportScanner::FCandidate> Candidates =
AssetImportScanner::Scan(ReadyRegistry(), ScanOptions, ScanStats);
TestFalse(TEXT("no manifest"), ScanStats.bManifestFound);
TestEqual(TEXT("one file"), ScanStats.FilesFound, 1);
if (Candidates.Num() != 1)
{
AddError(TEXT("expected exactly one candidate"));
return;
}
// The engine notes this field is not always serialised, so a fallback to the folder
// structure is an acceptable answer too - what must not happen is Unresolved, which
// would mean neither route worked on a file we just wrote ourselves.
TestNotEqual(TEXT("a path was recovered"),
Candidates[0].NameSource, AssetImportScanner::EPackageNameSource::Unresolved);
AddInfo(FString::Printf(TEXT("Recovered '%s' via %s."),
*Candidates[0].TargetPackage.ToString(),
AssetImportScanner::LexToString(Candidates[0].NameSource)));
});
});
Describe("Refusing to destroy existing work", [this]()
{
AfterEach([this]() { CleanUp(); });
It("skips an asset that already exists rather than overwriting it", [this]()
{
FName Dependency = NAME_None;
const FName Package = FindAssetWithGameDependency(Dependency);
if (Package.IsNone())
{
AddWarning(TEXT("No suitable asset was found; skipping."));
return;
}
MakeScratchDir(TEXT("ImportNoOverwrite"));
FAssetUsageExporter::FOptions ExportOptions;
ExportOptions.TargetDirectory = ScratchDir;
ExportOptions.bIncludeExternalPackages = false;
FAssetUsageExporter::ExportPackageFiles({ Package }, ExportOptions);
AssetImportScanner::FOptions ScanOptions;
ScanOptions.SourceDirectory = ScratchDir;
AssetImportScanner::FStats ScanStats;
const TArray<AssetImportScanner::FCandidate> Candidates =
AssetImportScanner::Scan(ReadyRegistry(), ScanOptions, ScanStats);
// Importing these back into the project they came from must be a no-op by default.
// If this ever passes with FilesImported > 0, the tool has just overwritten real
// content with a copy of itself.
AssetImporter::FOptions ImportOptions;
ImportOptions.bOverwriteExisting = false;
const AssetImporter::FResult ImportResult =
AssetImporter::Import(ReadyRegistry(), Candidates, ImportOptions);
TestEqual(TEXT("nothing was written"), ImportResult.FilesImported, 0);
TestTrue(TEXT("skipped or refused as loaded"),
ImportResult.SkippedExisting + ImportResult.SkippedLoaded == Candidates.Num());
});
It("skips a candidate with no recoverable path and no fallback folder", [this]()
{
AssetImportScanner::FCandidate Candidate;
Candidate.SourceFile = TEXT("D:/nowhere/Mystery.uasset");
Candidate.RelativePath = TEXT("Mystery.uasset");
Candidate.NameSource = AssetImportScanner::EPackageNameSource::Unresolved;
AssetImporter::FOptions Options;
const AssetImporter::FResult Result =
AssetImporter::Import(ReadyRegistry(), { Candidate }, Options);
TestEqual(TEXT("skipped"), Result.SkippedUnresolved, 1);
TestEqual(TEXT("nothing written"), Result.FilesImported, 0);
TestEqual(TEXT("not an error"), Result.Errors.Num(), 0);
});
});
Describe("Importing into the project", [this]()
{
AfterEach([this]() { CleanUp(); });
It("writes the file at the package path and makes it visible to the registry", [this]()
{
// The only spec that lets the importer write into this project's content, and it aims
// at a folder no real asset uses. CleanUp removes it whatever happens.
FName Dependency = NAME_None;
const FName Package = FindAssetWithGameDependency(Dependency);
if (Package.IsNone())
{
AddWarning(TEXT("No suitable asset was found; skipping."));
return;
}
MakeScratchDir(TEXT("ImportWrite"));
FAssetUsageExporter::FOptions ExportOptions;
ExportOptions.TargetDirectory = ScratchDir;
ExportOptions.bIncludeExternalPackages = false;
const FAssetUsageExporter::FResult ExportResult =
FAssetUsageExporter::ExportPackageFiles({ Package }, ExportOptions);
if (ExportResult.FilesCopied != 1)
{
AddError(TEXT("the fixture export did not produce a file"));
return;
}
TArray<FString> Files;
IFileManager::Get().FindFilesRecursive(Files, *ScratchDir, TEXT("*.uasset"), true, false);
if (Files.Num() != 1)
{
AddError(TEXT("expected one exported file"));
return;
}
// Aimed at the scratch root rather than at its original path, so nothing real is at risk.
const FName ScratchTarget(*FString::Printf(TEXT("%s/Imported"), ScratchPackageRoot()));
AssetImportScanner::FCandidate Candidate;
Candidate.SourceFile = Files[0];
Candidate.RelativePath = FPaths::GetCleanFilename(Files[0]);
Candidate.TargetPackage = ScratchTarget;
Candidate.NameSource = AssetImportScanner::EPackageNameSource::Manifest;
AssetImporter::FOptions ImportOptions;
const AssetImporter::FResult ImportResult =
AssetImporter::Import(ReadyRegistry(), { Candidate }, ImportOptions);
TestEqual(TEXT("one file imported"), ImportResult.FilesImported, 1);
TestTrue(TEXT("succeeded"), ImportResult.bSuccess);
FString ExpectedFile;
FPackageName::TryConvertLongPackageNameToFilename(
ScratchTarget.ToString(), ExpectedFile, FPackageName::GetAssetPackageExtension());
TestTrue(TEXT("the file is at the package path"), IFileManager::Get().FileExists(*ExpectedFile));
// Copying is not enough: without the rescan the asset exists on disk and is invisible
// in the Content Browser, which reads as an import that did nothing.
TArray<FAssetData> Found;
ReadyRegistry().GetAssetsByPackageName(ScratchTarget, Found, /*bIncludeOnlyOnDiskAssets=*/true);
TestTrue(TEXT("the registry can see it"), Found.Num() > 0);
});
});
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,236 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetUsageAuditTypes.h"
#include "AssetUsagePaths.h"
#include "AssetRegistry/ARFilter.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "AssetToolsModule.h"
#include "Engine/World.h"
#include "HAL/FileManager.h"
#include "IAssetTools.h"
#include "Misc/AutomationTest.h"
#include "Misc/AssetRegistryInterface.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#if WITH_DEV_AUTOMATION_TESTS
/**
* Migrate, run for real against a throwaway destination project.
*
* Everything else about Migrate is covered by reading the engine's rules; this is the only spec
* that proves the call actually moves files. It matters because MigratePackages returns void and
* reports failures to the Output Log alone - the mode shipped once already in a state where it did
* nothing at all and looked like a dead button.
*
* The destination is a minimal project skeleton under Saved/: a .uproject beside a Content folder
* is exactly what the engine checks for, and building one costs nothing compared to requiring a
* second real project on disk.
*/
BEGIN_DEFINE_SPEC(MigrateLiveSpec,
"AssetUsageAudit.MigrateLive",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
FString ScratchDir;
FString DestinationContentDir;
void MakeDestinationProject(const TCHAR* Suffix)
{
ScratchDir = FPaths::ConvertRelativePathToFull(
FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("AssetUsageAuditTests"), Suffix));
IFileManager::Get().DeleteDirectory(*ScratchDir, false, true);
const FString ProjectRoot = FPaths::Combine(ScratchDir, TEXT("MigrateTarget"));
DestinationContentDir = FPaths::Combine(ProjectRoot, TEXT("Content"));
IFileManager::Get().MakeDirectory(*DestinationContentDir, true);
// Contents are never parsed - the engine only looks for a file with this extension beside
// the Content folder in order to decide the destination mount point is /Game/.
FFileHelper::SaveStringToFile(TEXT("{}"), *FPaths::Combine(ProjectRoot, TEXT("MigrateTarget.uproject")));
}
void RemoveScratchDir()
{
if (!ScratchDir.IsEmpty())
{
IFileManager::Get().DeleteDirectory(*ScratchDir, false, true);
}
}
static IAssetRegistry& ReadyRegistry()
{
IAssetRegistry& Registry = IAssetRegistry::GetChecked();
if (Registry.IsLoadingAssets())
{
Registry.WaitForCompletion();
}
return Registry;
}
/**
* A /Game asset that has at least one /Game dependency of its own.
*
* The dependency is the point: a migrated asset arriving without the things it references is
* the failure this whole layout exists to avoid, and an asset with no dependencies could not
* detect it. Sorted so a failure is reproducible.
*/
static FName FindAssetWithGameDependency(FName& OutDependency)
{
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 (AssetData.PackageName.ToString().Contains(TEXT("__External")))
{
return true;
}
Candidates.Add(AssetData.PackageName);
return Candidates.Num() < 4096;
});
Candidates.Sort(FNameLexicalLess());
TArray<FName> Dependencies;
for (FName Candidate : Candidates)
{
Dependencies.Reset();
Registry.GetDependencies(
Candidate,
Dependencies,
AssetUsageAudit::MakeTraversalCategory(),
AssetUsageAudit::MakeTraversalQuery());
for (FName Dependency : Dependencies)
{
const FString AsString = Dependency.ToString();
if (AsString.StartsWith(TEXT("/Game/")) && !AsString.Contains(TEXT("__External")))
{
OutDependency = Dependency;
return Candidate;
}
}
}
return NAME_None;
}
/** Where a /Game package should land under the destination Content folder. */
FString ExpectedDestinationFile(FName PackageName) const
{
FString Relative = PackageName.ToString();
Relative.RemoveFromStart(TEXT("/Game/"));
return FPaths::Combine(DestinationContentDir, Relative + TEXT(".uasset"));
}
END_DEFINE_SPEC(MigrateLiveSpec)
void MigrateLiveSpec::Define()
{
Describe("Migrating into a destination project", [this]()
{
AfterEach([this]() { RemoveScratchDir(); });
It("places the asset at its original package path, with its dependency", [this]()
{
FName Dependency = NAME_None;
const FName Package = FindAssetWithGameDependency(Dependency);
if (Package.IsNone())
{
AddWarning(TEXT("No /Game asset with a /Game dependency was found; skipping."));
return;
}
MakeDestinationProject(TEXT("MigrateLive"));
AddInfo(FString::Printf(TEXT("Migrating '%s', which references '%s'."),
*Package.ToString(), *Dependency.ToString()));
// The destination must pass our own check first. If it does not, the failure below
// would be about the fixture rather than about Migrate.
TestTrue(TEXT("the fixture is a valid migrate destination"),
AssetUsagePaths::ValidateMigrateDestination(DestinationContentDir).IsEmpty());
// Migrate loads every package it moves, and this project's ConventionKeeper plugin
// validates asset names on load - so migrating real content emits Error lines about
// naming violations in assets this spec did not choose and cannot fix. The automation
// framework treats any logged Error as a failure, so they have to be declared.
//
// Deliberately matched loosely and with no expected count: which assets come along
// depends on the dependency closure of whichever asset the search picked, and pinning
// that would make the spec fail whenever content changes.
AddExpectedError(TEXT("asset-naming"), EAutomationExpectedErrorFlags::Contains, 0);
FMigrationOptions Options;
Options.bPrompt = false;
Options.bIgnoreDependencies = false;
Options.AssetConflict = EAssetMigrationConflict::Overwrite;
IAssetTools::Get().MigratePackages({ Package }, DestinationContentDir, Options);
const FString ExpectedAsset = ExpectedDestinationFile(Package);
const FString ExpectedDependency = ExpectedDestinationFile(Dependency);
// The whole point of the mode: the file has to be at the same package path, because
// that is how the reference inside it is resolved.
TestTrue(*FString::Printf(TEXT("the asset landed at %s"), *ExpectedAsset),
IFileManager::Get().FileExists(*ExpectedAsset));
TestTrue(*FString::Printf(TEXT("its dependency landed at %s"), *ExpectedDependency),
IFileManager::Get().FileExists(*ExpectedDependency));
});
It("writes nothing when the destination is not a Content folder", [this]()
{
// Guards the case that shipped broken: Migrate refuses, logs, and returns void, so
// without our own check the caller cannot tell success from silence.
FName Dependency = NAME_None;
const FName Package = FindAssetWithGameDependency(Dependency);
if (Package.IsNone())
{
AddWarning(TEXT("No suitable asset was found; skipping."));
return;
}
MakeDestinationProject(TEXT("MigrateLiveBadDest"));
// One level up from Content: right project, wrong folder.
const FString BadDestination = FPaths::GetPath(DestinationContentDir);
TestFalse(TEXT("our check rejects it"),
AssetUsagePaths::ValidateMigrateDestination(BadDestination).IsEmpty());
AddExpectedError(TEXT("does not appear to be a game Content folder"), EAutomationExpectedErrorFlags::Contains, 0);
FMigrationOptions Options;
Options.bPrompt = false;
IAssetTools::Get().MigratePackages({ Package }, BadDestination, Options);
TestFalse(TEXT("nothing was written into the project root"),
IFileManager::Get().FileExists(*FPaths::Combine(BadDestination, TEXT("MigrateTarget.uasset"))));
});
});
}
#endif // WITH_DEV_AUTOMATION_TESTS