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