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 "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()
|
||||
|
||||
@@ -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",
|
||||
"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"
|
||||
});
|
||||
|
||||
@@ -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