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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user