aeb4a1067c
The plugin was authored outside studio work but carried NextGenium attribution in the .uplugin descriptor and in every source header. Co-Authored-By: Claude Code <noreply@anthropic.com>
187 lines
6.3 KiB
C++
187 lines
6.3 KiB
C++
// MagentaDolphin 2026. Asset Usage Audit.
|
|
|
|
#include "AssetUsagePaths.h"
|
|
#include "HAL/FileManager.h"
|
|
#include "Misc/Paths.h"
|
|
#include "Misc/StringBuilder.h"
|
|
#include "String/Find.h"
|
|
|
|
namespace AssetUsagePaths
|
|
{
|
|
namespace Private
|
|
{
|
|
// These two literals match FPackagePath's folder names (CoreUObject PackagePath.cpp).
|
|
// They are used only to CLASSIFY an existing path. Producing an external-actor path is
|
|
// ULevel::GetExternalActorsPaths's job, because that form has /CB/ and /EDL/ variants and
|
|
// is extensible by plugin delegates - string building it here would be silently wrong.
|
|
static const TCHAR* ExternalActorsFolder = TEXT("__ExternalActors__");
|
|
static const TCHAR* ExternalObjectsFolder = TEXT("__ExternalObjects__");
|
|
|
|
static const TCHAR* GameRoot = TEXT("/Game");
|
|
static const TCHAR* ContentPrefix = TEXT("Content");
|
|
|
|
/** Prefix match that only accepts a whole path segment, so /Game/Art misses /Game/ArtSource. */
|
|
static bool StartsWithSegment(FStringView Path, FStringView Prefix)
|
|
{
|
|
if (Prefix.IsEmpty() || Path.Len() < Prefix.Len())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!Path.Left(Prefix.Len()).Equals(Prefix, ESearchCase::IgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return Path.Len() == Prefix.Len() || Path[Prefix.Len()] == TEXT('/');
|
|
}
|
|
}
|
|
|
|
bool IsExternalPackage(FStringView PackagePath)
|
|
{
|
|
return UE::String::FindFirst(PackagePath, Private::ExternalActorsFolder, ESearchCase::IgnoreCase) != INDEX_NONE
|
|
|| UE::String::FindFirst(PackagePath, Private::ExternalObjectsFolder, ESearchCase::IgnoreCase) != INDEX_NONE;
|
|
}
|
|
|
|
bool IsExternalPackage(FName PackageName)
|
|
{
|
|
TStringBuilder<256> Builder;
|
|
PackageName.ToString(Builder);
|
|
return IsExternalPackage(Builder.ToView());
|
|
}
|
|
|
|
bool IsScriptPackage(FStringView PackagePath)
|
|
{
|
|
return Private::StartsWithSegment(PackagePath, TEXTVIEW("/Script"));
|
|
}
|
|
|
|
bool IsScriptPackage(FName PackageName)
|
|
{
|
|
TStringBuilder<256> Builder;
|
|
PackageName.ToString(Builder);
|
|
return IsScriptPackage(Builder.ToView());
|
|
}
|
|
|
|
bool IsEngineOrTempPackage(FStringView PackagePath)
|
|
{
|
|
return Private::StartsWithSegment(PackagePath, TEXTVIEW("/Engine"))
|
|
|| Private::StartsWithSegment(PackagePath, TEXTVIEW("/Temp"));
|
|
}
|
|
|
|
FString NormalizeExclusionPrefix(const FString& Prefix)
|
|
{
|
|
FString Working = Prefix;
|
|
Working.TrimStartAndEndInline();
|
|
Working.ReplaceInline(TEXT("\\"), TEXT("/"), ESearchCase::CaseSensitive);
|
|
|
|
while (Working.EndsWith(TEXT("/"), ESearchCase::CaseSensitive))
|
|
{
|
|
Working.LeftChopInline(1, EAllowShrinking::No);
|
|
}
|
|
|
|
if (Working.IsEmpty())
|
|
{
|
|
return FString();
|
|
}
|
|
|
|
// Content-relative form, which is what the UI shows and what settings files accumulate.
|
|
if (Private::StartsWithSegment(Working, Private::ContentPrefix))
|
|
{
|
|
const FString Remainder = Working.RightChop(FCString::Strlen(Private::ContentPrefix));
|
|
return FString(Private::GameRoot) + Remainder;
|
|
}
|
|
|
|
if (!Working.StartsWith(TEXT("/"), ESearchCase::CaseSensitive))
|
|
{
|
|
Working.InsertAt(0, TEXT("/"));
|
|
}
|
|
|
|
return Working;
|
|
}
|
|
|
|
bool IsPathExcluded(FStringView PackagePath, const TArray<FString>& ExcludedPrefixes)
|
|
{
|
|
for (const FString& RawPrefix : ExcludedPrefixes)
|
|
{
|
|
const FString Normalized = NormalizeExclusionPrefix(RawPrefix);
|
|
if (!Normalized.IsEmpty() && Private::StartsWithSegment(PackagePath, Normalized))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool IsPathExcluded(FName PackageName, const TArray<FString>& ExcludedPrefixes)
|
|
{
|
|
if (ExcludedPrefixes.IsEmpty())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
TStringBuilder<256> Builder;
|
|
PackageName.ToString(Builder);
|
|
return IsPathExcluded(Builder.ToView(), ExcludedPrefixes);
|
|
}
|
|
|
|
FString ToProjectRelativePath(FName PackageName, bool bIsLevel)
|
|
{
|
|
TStringBuilder<256> Builder;
|
|
PackageName.ToString(Builder);
|
|
const FStringView PackagePath = Builder.ToView();
|
|
|
|
if (!Private::StartsWithSegment(PackagePath, Private::GameRoot))
|
|
{
|
|
// /Script, /Engine and plugin mounts have no project-relative form. Reporting an
|
|
// invented path for them would send an artist looking for a file that is not there.
|
|
return FString();
|
|
}
|
|
|
|
const FStringView Remainder = PackagePath.RightChop(FCString::Strlen(Private::GameRoot));
|
|
const TCHAR* Extension = bIsLevel ? TEXT(".umap") : TEXT(".uasset");
|
|
|
|
return FString::Printf(TEXT("Content%.*s%s"), Remainder.Len(), Remainder.GetData(), Extension);
|
|
}
|
|
|
|
FString ValidateMigrateDestination(const FString& Directory)
|
|
{
|
|
if (Directory.IsEmpty())
|
|
{
|
|
return TEXT("Choose the destination project's Content folder.");
|
|
}
|
|
|
|
FString Normalised = Directory;
|
|
FPaths::NormalizeDirectoryName(Normalised);
|
|
Normalised += TEXT("/");
|
|
|
|
if (!Normalised.EndsWith(TEXT("/Content/")))
|
|
{
|
|
return TEXT("Migrate needs a folder named Content - pick the destination project's Content folder itself, not the project root.");
|
|
}
|
|
|
|
// Second rule. A folder merely named Content passes the first check and Migrate still
|
|
// aborts: the destination mount point is derived from what sits beside it.
|
|
const FString Parent = Normalised + TEXT("../");
|
|
|
|
TArray<FString> Found;
|
|
IFileManager::Get().FindFiles(Found, *Parent, TEXT("uproject"));
|
|
|
|
if (!Found.IsEmpty())
|
|
{
|
|
return FString();
|
|
}
|
|
|
|
IFileManager::Get().FindFiles(Found, *Parent, TEXT("uplugin"));
|
|
|
|
if (Found.Num() == 1)
|
|
{
|
|
return FString();
|
|
}
|
|
|
|
return Found.Num() > 1
|
|
? TEXT("That folder's parent holds several .uplugin files, so Unreal cannot tell which mount point to migrate into.")
|
|
: TEXT("No .uproject or .uplugin next to that Content folder, so Unreal has no mount point to migrate into.");
|
|
}
|
|
}
|