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:
@@ -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
|
||||||
Reference in New Issue
Block a user