feat: import assets from an external folder

Reverse direction of the export: read a folder of .uasset files, recover
each one's original package path, and bring the chosen ones back.

Recovering the path is the hard part - a reference inside a .uasset names
its target by full package path, so a file only works when restored to
where it came from. Four sources are tried and the one used is shown per
row: our manifest, the package header, the folder structure, or nothing.

The package header carries the name on this project's assets, measured,
so a flat export without a manifest still restores correctly. The engine
notes the field is not always written, hence the chain rather than one
check.

Import refuses to destroy work: existing assets are skipped unless
overwrite is explicitly on, and a package loaded in the editor is never
replaced.

Also verifies Migrate on real data for the first time - asset and its
dependency land at the correct package paths.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-03 17:39:58 +07:00
parent 1e351cbb9b
commit 243bb1fc55
11 changed files with 2032 additions and 0 deletions
@@ -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