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,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;
}
}