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:
@@ -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