From 569ec56a1919f21374de127daa8ec79d04f69ef7 Mon Sep 17 00:00:00 2001 From: "[DEV]PolyakovDanil" Date: Thu, 3 Sep 2026 17:39:58 +0700 Subject: [PATCH] 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 --- README.md | 33 + .../Private/AssetImportScanner.cpp | 332 ++++++++++ .../Private/AssetImporter.cpp | 186 ++++++ .../Public/AssetImportScanner.h | 120 ++++ .../Public/AssetImporter.h | 87 +++ .../Private/AssetUsageAuditEditorModule.cpp | 13 + .../Private/SAssetImportDialog.cpp | 619 ++++++++++++++++++ .../Private/SAssetImportDialog.h | 16 + .../AssetUsageAuditTests.Build.cs | 5 + .../Private/AssetImportRoundTrip.spec.cpp | 385 +++++++++++ .../Private/MigrateLive.spec.cpp | 236 +++++++ 11 files changed, 2032 insertions(+) create mode 100644 Source/AssetUsageAuditCore/Private/AssetImportScanner.cpp create mode 100644 Source/AssetUsageAuditCore/Private/AssetImporter.cpp create mode 100644 Source/AssetUsageAuditCore/Public/AssetImportScanner.h create mode 100644 Source/AssetUsageAuditCore/Public/AssetImporter.h create mode 100644 Source/AssetUsageAuditEditor/Private/SAssetImportDialog.cpp create mode 100644 Source/AssetUsageAuditEditor/Private/SAssetImportDialog.h create mode 100644 Source/AssetUsageAuditTests/Private/AssetImportRoundTrip.spec.cpp create mode 100644 Source/AssetUsageAuditTests/Private/MigrateLive.spec.cpp diff --git a/README.md b/README.md index e6098a5..525b832 100644 --- a/README.md +++ b/README.md @@ -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, что читается как «импорт ничего не сделал». + +--- + ## Архитектура ``` diff --git a/Source/AssetUsageAuditCore/Private/AssetImportScanner.cpp b/Source/AssetUsageAuditCore/Private/AssetImportScanner.cpp new file mode 100644 index 0000000..3a827f5 --- /dev/null +++ b/Source/AssetUsageAuditCore/Private/AssetImportScanner.cpp @@ -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 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 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& OutPackageByRelativePath, + TMap>& OutDependenciesByPackage) + { + const FString ManifestPath = FPaths::Combine(SourceDirectory, AssetExportManifest::FileName); + + FString Text; + if (!FFileHelper::LoadFileToString(Text, *ManifestPath)) + { + return false; + } + + TSharedPtr Root; + const TSharedRef> 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>* Assets = nullptr; + if (!Root->TryGetArrayField(TEXT("assets"), Assets) || !Assets) + { + return false; + } + + for (const TSharedPtr& Value : *Assets) + { + const TSharedPtr 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>* Dependencies = nullptr; + if (Entry->TryGetArrayField(TEXT("dependencies"), Dependencies) && Dependencies) + { + TArray& List = OutDependenciesByPackage.FindOrAdd(FName(*PackageName)); + + for (const TSharedPtr& DependencyValue : *Dependencies) + { + const TSharedPtr 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 Scan(IAssetRegistry& Registry, const FOptions& Options, FStats& OutStats) + { + OutStats = FStats(); + + TArray 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 PackageByRelativePath; + TMap> DependenciesByPackage; + + OutStats.bManifestFound = Private::LoadManifest(Options.SourceDirectory, PackageByRelativePath, DependenciesByPackage); + + TArray Files; + IFileManager::Get().FindFilesRecursive(Files, *Options.SourceDirectory, TEXT("*.uasset"), true, false); + + TArray 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 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* 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 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 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; + } +} diff --git a/Source/AssetUsageAuditCore/Private/AssetImporter.cpp b/Source/AssetUsageAuditCore/Private/AssetImporter.cpp new file mode 100644 index 0000000..5578988 --- /dev/null +++ b/Source/AssetUsageAuditCore/Private/AssetImporter.cpp @@ -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 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& 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; + } +} diff --git a/Source/AssetUsageAuditCore/Public/AssetImportScanner.h b/Source/AssetUsageAuditCore/Public/AssetImportScanner.h new file mode 100644 index 0000000..f364316 --- /dev/null +++ b/Source/AssetUsageAuditCore/Public/AssetImportScanner.h @@ -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 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 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); +} diff --git a/Source/AssetUsageAuditCore/Public/AssetImporter.h b/Source/AssetUsageAuditCore/Public/AssetImporter.h new file mode 100644 index 0000000..4b11f46 --- /dev/null +++ b/Source/AssetUsageAuditCore/Public/AssetImporter.h @@ -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 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 Errors; + + /** Absolute paths written, for the Asset Registry rescan that makes them visible. */ + TArray 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& Candidates, + const FOptions& Options); +} diff --git a/Source/AssetUsageAuditEditor/Private/AssetUsageAuditEditorModule.cpp b/Source/AssetUsageAuditEditor/Private/AssetUsageAuditEditorModule.cpp index f0aa777..5bd0498 100644 --- a/Source/AssetUsageAuditEditor/Private/AssetUsageAuditEditorModule.cpp +++ b/Source/AssetUsageAuditEditor/Private/AssetUsageAuditEditorModule.cpp @@ -5,6 +5,7 @@ #include "AssetUsageAuditSettings.h" #include "AssetUsageAuditor.h" #include "AssetUsageReportWriter.h" +#include "SAssetImportDialog.h" #include "SAssetUsageAuditPanel.h" #include "AssetRegistry/AssetRegistryModule.h" #include "AssetRegistry/IAssetRegistry.h" @@ -90,6 +91,18 @@ void FAssetUsageAuditEditorModule::RegisterMenus() { 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() diff --git a/Source/AssetUsageAuditEditor/Private/SAssetImportDialog.cpp b/Source/AssetUsageAuditEditor/Private/SAssetImportDialog.cpp new file mode 100644 index 0000000..942f71d --- /dev/null +++ b/Source/AssetUsageAuditEditor/Private/SAssetImportDialog.cpp @@ -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; + +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 +{ +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& InOwnerTable) + { + Item = InArgs._Item; + OnCheckChanged = InArgs._OnCheckChanged; + CheckState = InArgs._CheckState; + + SMultiColumnTableRow::Construct(FSuperRowType::FArguments(), InOwnerTable); + } + + virtual TSharedRef 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 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 CheckState; +}; + +/** The import window body. */ +class SAssetImportDialog : public SCompoundWidget +{ +public: + SLATE_BEGIN_ARGS(SAssetImportDialog) {} + SLATE_ARGUMENT(TSharedPtr, 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) + .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 OnGenerateRow(FImportRowPtr Item, const TSharedRef& OwnerTable) + { + return SNew(SImportRowWidget, OwnerTable) + .Item(Item) + .CheckState(TAttribute::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(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 Candidates = + AssetImportScanner::Scan(Registry, Options, Stats); + + Rows.Reserve(Candidates.Num()); + for (const AssetImportScanner::FCandidate& Candidate : Candidates) + { + Rows.Add(MakeShared(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 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(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(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 Rows; + + /** Ticked rows, keyed by source file so ticks survive a re-scan. */ + TSet CheckedFiles; + + FText StatusText; + + TSharedPtr> ListView; + TSharedPtr HeaderRow; + TSharedPtr ParentWindow; +}; + +void ShowAssetImportDialog() +{ + TSharedRef 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 diff --git a/Source/AssetUsageAuditEditor/Private/SAssetImportDialog.h b/Source/AssetUsageAuditEditor/Private/SAssetImportDialog.h new file mode 100644 index 0000000..410aadb --- /dev/null +++ b/Source/AssetUsageAuditEditor/Private/SAssetImportDialog.h @@ -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(); diff --git a/Source/AssetUsageAuditTests/AssetUsageAuditTests.Build.cs b/Source/AssetUsageAuditTests/AssetUsageAuditTests.Build.cs index 0bca56d..483d4e1 100644 --- a/Source/AssetUsageAuditTests/AssetUsageAuditTests.Build.cs +++ b/Source/AssetUsageAuditTests/AssetUsageAuditTests.Build.cs @@ -19,6 +19,11 @@ public class AssetUsageAuditTests : ModuleRules { "AssetRegistry", "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", "Projects" }); diff --git a/Source/AssetUsageAuditTests/Private/AssetImportRoundTrip.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetImportRoundTrip.spec.cpp new file mode 100644 index 0000000..c10d349 --- /dev/null +++ b/Source/AssetUsageAuditTests/Private/AssetImportRoundTrip.spec.cpp @@ -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 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 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 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 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 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 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 Found; + ReadyRegistry().GetAssetsByPackageName(ScratchTarget, Found, /*bIncludeOnlyOnDiskAssets=*/true); + + TestTrue(TEXT("the registry can see it"), Found.Num() > 0); + }); + }); +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/AssetUsageAuditTests/Private/MigrateLive.spec.cpp b/Source/AssetUsageAuditTests/Private/MigrateLive.spec.cpp new file mode 100644 index 0000000..27b72e6 --- /dev/null +++ b/Source/AssetUsageAuditTests/Private/MigrateLive.spec.cpp @@ -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 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 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