From ee1c8bad3ef3b473e3cc1235471da4b3a5658f93 Mon Sep 17 00:00:00 2001 From: MagentaDolphin Date: Mon, 7 Sep 2026 17:14:39 +0700 Subject: [PATCH 1/8] feat: asset deletion scan and delete dialog (WIP) Adds AssetDeletionScan to Core (no UI/editor-pipeline deps, invariant holds) and SAssetDeleteDialog to the Editor module, plus 10 spec cases. Co-Authored-By: Claude Code --- README.md | 46 ++ .../Private/AssetDeletionScan.cpp | 243 +++++++ .../Public/AssetDeletionScan.h | 151 ++++ .../Private/SAssetDeleteDialog.cpp | 669 ++++++++++++++++++ .../Private/SAssetDeleteDialog.h | 46 ++ .../Private/SAssetUsageAuditPanel.cpp | 105 +++ .../Private/SAssetUsageAuditPanel.h | 25 + .../Private/AssetDeletionScan.spec.cpp | 449 ++++++++++++ 8 files changed, 1734 insertions(+) create mode 100644 Source/AssetUsageAuditCore/Private/AssetDeletionScan.cpp create mode 100644 Source/AssetUsageAuditCore/Public/AssetDeletionScan.h create mode 100644 Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.cpp create mode 100644 Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.h create mode 100644 Source/AssetUsageAuditTests/Private/AssetDeletionScan.spec.cpp diff --git a/README.md b/README.md index 525b832..5ec55b5 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,52 @@ BOM обязателен: без него Excel ломает кириллицу. --- +## Удаление отмеченных ассетов + +Кнопка **Delete ticked assets…** внизу панели, рядом с выгрузкой. Работает по тому же набору галочек. + +Удаление — единственная операция инструмента, которая уничтожает работу безвозвратно, поэтому проходит **через два окна**. + +### Окно 1 — наше + +Показывает весь отмеченный набор построчно: имя, тип, путь, **сколько ассетов снаружи набора ещё ссылается** на строку (в тултипе — их имена), и статус. + +- Ссылающиеся ассеты, которые сами удаляются вместе с целью, **в счётчик не попадают**. Иначе удаление блюпринта вместе с его единственным мешем выглядело бы опасным, хотя оно чистое. +- Референсеры запрашиваются тем же `NoRequirements`-запросом, что и аудит: hard-only потерял бы каждую soft-ссылку, а soft-ссылка ломается точно так же — просто в рантайме, а не при загрузке. +- Файл, помеченный **read-only на диске**, отмечается заранее. Проект Perforce-primary, невытянутый файл нередактируемый, и без этой пометки удаление падало бы по одному файлу в середине пачки. +- Кнопка удаления неактивна, пока не поставлена галочка «I understand these files will be removed from the project». + +### Окно 2 — родное от UE + +После подтверждения набор уходит в `ObjectTools::DeleteAssets(..., bShowConfirmation=true)`, и открывается **штатное окно Delete Assets** движка — со списком ссылок, `Force Delete` и `Replace References`. + +Оно не имитировано, а вызвано: своя реализация замены ссылок означала бы свою реализацию `FAssetDeleteModel`, ошибки которой всплыли бы на чужом проекте. + +### Что удалять запрещено + +Отказы показываются в списке с причиной, строку нельзя отметить — молча выкинуть её из набора было бы хуже, чем отказать вслух. + +| Отказ | Почему | +|---|---| +| **Уровень** (`.umap`) | Уровень — это единица, относительно которой инструмент меряет использование. Удалив его, обесцениваешь каждую другую строку результата. Плюс движок сам отказывается удалять открытую карту, и итог зависел бы от того, какая карта сейчас загружена | +| **OFPA-пакет** (`__ExternalActors__`, `__ExternalObjects__`) | Это не ассет, а размещённый на карте актор. Удалять его мимо level-редактора — значит править карту за его спиной, без его undo | +| **`/Engine`, `/Script`, `/Temp`** | Не контент этого проекта | +| **Уже отсутствует** | В реестре нет ассета под этим именем — удалять нечего | + +### ⚠️ Набор для удаления не расширяется зависимостями + +В отличие от выгрузки. Выгрузка тянет зависимости, чтобы меш приехал с материалами; удаление, расширенное так же, снесло бы контент, который никто не отмечал, — одна общая текстура утащила бы половину проекта. + +Удаляется ровно отмеченное и никогда больше. + +### ⚠️ После удаления результат помечается устаревшим + +Удалённые строки убираются из таблицы, но всё, что на них ссылалось, сохраняет прежние счётчики ссылок, а ассет, бывший `UsedByAssetsOnly` через удалённый блюпринт, теперь имеет другой вердикт. Панель говорит об этом прямо и просит перезапустить аудит, а не выдаёт старые числа за свежий замер. + +И главное — то же, что и везде в этом инструменте: **`Unreferenced` не означает «не используется»**. Реестр не видит путей, собранных строками, содержимого DataTable и событий FMOD. Окно удаления повторяет это предупреждение прямо в тексте. + +--- + ## Архитектура ``` diff --git a/Source/AssetUsageAuditCore/Private/AssetDeletionScan.cpp b/Source/AssetUsageAuditCore/Private/AssetDeletionScan.cpp new file mode 100644 index 0000000..812a20d --- /dev/null +++ b/Source/AssetUsageAuditCore/Private/AssetDeletionScan.cpp @@ -0,0 +1,243 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetDeletionScan.h" + +#include "AssetRegistry/IAssetRegistry.h" +#include "AssetUsageAuditTypes.h" +#include "AssetUsagePaths.h" +#include "Engine/World.h" +#include "HAL/FileManager.h" +#include "Misc/PackageName.h" + +#define LOCTEXT_NAMESPACE "AssetDeletionScan" + +namespace AssetDeletionScan +{ + const TCHAR* LexToString(ERefusal Refusal) + { + switch (Refusal) + { + case ERefusal::None: return TEXT("None"); + case ERefusal::IsLevel: return TEXT("IsLevel"); + case ERefusal::IsExternalPackage: return TEXT("IsExternalPackage"); + case ERefusal::NotProjectContent: return TEXT("NotProjectContent"); + case ERefusal::Missing: return TEXT("Missing"); + } + + return TEXT("Unknown"); + } + + FText DescribeRefusal(ERefusal Refusal) + { + switch (Refusal) + { + case ERefusal::None: + return FText::GetEmpty(); + + case ERefusal::IsLevel: + return LOCTEXT("RefusalLevel", + "Levels are never deleted by this tool. Delete a map from the Content Browser if you really mean to."); + + case ERefusal::IsExternalPackage: + return LOCTEXT("RefusalExternal", + "This is a single actor stored outside its map (One File Per Actor). Delete it in the level editor, where it can be undone."); + + case ERefusal::NotProjectContent: + return LOCTEXT("RefusalNotProject", + "Engine or plugin content - not part of this project."); + + case ERefusal::Missing: + return LOCTEXT("RefusalMissing", + "No asset found under this package name; nothing to delete."); + } + + return FText::GetEmpty(); + } + + FString FStats::Summarise() const + { + return FString::Printf( + TEXT("%d ticked: %d deletable, %d refused (levels %d, external actors %d, engine %d, missing %d); ") + TEXT("%d still referenced from outside the set, %d read-only on disk."), + Requested, + Deletable, + Requested - Deletable, + RefusedLevels, + RefusedExternal, + RefusedNotProjectContent, + RefusedMissing, + WithOutsideReferencers, + ReadOnly); + } + + TArray Scan( + IAssetRegistry& Registry, + const TArray& Packages, + const FOptions& Options, + FStats& OutStats) + { + OutStats = FStats(); + + TArray Candidates; + Candidates.Reserve(Packages.Num()); + + // Membership is needed while classifying referencers, so it is built first and in full. + // Deleting A and B where B is A's only referencer must not report A as still in use. + TSet DeleteSet(Packages); + + // Answering "is this a map" costs a registry query that allocates; the same package shows up + // as a referencer of many candidates, so the answer is cached across the whole scan. + TMap LevelCache; + + const auto IsLevelPackage = [&Registry, &LevelCache](FName PackageName) -> bool + { + if (const bool* Cached = LevelCache.Find(PackageName)) + { + return *Cached; + } + + TArray Assets; + Registry.GetAssetsByPackageName(PackageName, Assets, /*bIncludeOnlyOnDiskAssets=*/true); + + bool bIsLevel = false; + for (const FAssetData& AssetData : Assets) + { + if (AssetData.AssetClassPath == UWorld::StaticClass()->GetClassPathName()) + { + bIsLevel = true; + break; + } + } + + LevelCache.Add(PackageName, bIsLevel); + return bIsLevel; + }; + + TArray Referencers; + TArray Assets; + + for (FName PackageName : Packages) + { + if (PackageName.IsNone()) + { + continue; + } + + ++OutStats.Requested; + + FCandidate Candidate; + Candidate.PackageName = PackageName; + + const FString PackageString = PackageName.ToString(); + + // Filled from the path before any refusal, so a refused row still shows a name and a + // path. A row that says only "refused" with an empty name is not an explanation. + Candidate.AssetName = FName(*FPackageName::GetShortName(PackageString)); + Candidate.PathFromProjectRoot = AssetUsagePaths::ToProjectRelativePath(PackageName, /*bIsLevel=*/false); + + // Path-shaped refusals come first: they need no registry lookup, and two of them + // (script and engine content) would not produce a sensible FAssetData anyway. + if (AssetUsagePaths::IsScriptPackage(PackageName) || AssetUsagePaths::IsEngineOrTempPackage(PackageString)) + { + Candidate.Refusal = ERefusal::NotProjectContent; + ++OutStats.RefusedNotProjectContent; + Candidates.Add(MoveTemp(Candidate)); + continue; + } + + if (AssetUsagePaths::IsExternalPackage(PackageName)) + { + Candidate.Refusal = ERefusal::IsExternalPackage; + ++OutStats.RefusedExternal; + Candidates.Add(MoveTemp(Candidate)); + continue; + } + + Assets.Reset(); + Registry.GetAssetsByPackageName(PackageName, Assets, /*bIncludeOnlyOnDiskAssets=*/true); + + if (Assets.Num() == 0) + { + Candidate.Refusal = ERefusal::Missing; + ++OutStats.RefusedMissing; + Candidates.Add(MoveTemp(Candidate)); + continue; + } + + // The package's own asset, preferring the one the package is named after. A package + // usually holds exactly one, but the first entry is not guaranteed to be the UAsset. + const FAssetData* Primary = Assets.FindByPredicate([](const FAssetData& Data) { return Data.IsUAsset(); }); + const FAssetData& AssetData = Primary ? *Primary : Assets[0]; + + Candidate.AssetName = AssetData.AssetName; + Candidate.ClassPath = AssetData.AssetClassPath; + + const bool bIsLevel = AssetData.AssetClassPath == UWorld::StaticClass()->GetClassPathName(); + LevelCache.Add(PackageName, bIsLevel); + + Candidate.PathFromProjectRoot = AssetUsagePaths::ToProjectRelativePath(PackageName, bIsLevel); + + if (bIsLevel) + { + Candidate.Refusal = ERefusal::IsLevel; + ++OutStats.RefusedLevels; + Candidates.Add(MoveTemp(Candidate)); + continue; + } + + // From here the candidate is deletable, and the remaining work is describing the + // consequences rather than deciding them. + Referencers.Reset(); + Registry.GetReferencers( + PackageName, Referencers, + AssetUsageAudit::MakeTraversalCategory(), AssetUsageAudit::MakeTraversalQuery()); + + for (FName Referencer : Referencers) + { + if (Referencer == PackageName || DeleteSet.Contains(Referencer)) + { + continue; + } + + ++Candidate.OutsideReferencerCount; + + // An OFPA package is counted as a level reference without a registry lookup: it + // exists only to hold an actor placed in some map, so a reference from one means + // the asset is in a level right now. + if (AssetUsagePaths::IsExternalPackage(Referencer) || IsLevelPackage(Referencer)) + { + ++Candidate.LevelReferencerCount; + } + + if (Candidate.OutsideReferencers.Num() < Options.MaxReferencersListed) + { + Candidate.OutsideReferencers.Add(Referencer); + } + } + + FString Filename; + if (FPackageName::DoesPackageExist(PackageString, &Filename)) + { + Candidate.bReadOnlyOnDisk = IFileManager::Get().IsReadOnly(*Filename); + } + + ++OutStats.Deletable; + + if (Candidate.OutsideReferencerCount > 0) + { + ++OutStats.WithOutsideReferencers; + } + + if (Candidate.bReadOnlyOnDisk) + { + ++OutStats.ReadOnly; + } + + Candidates.Add(MoveTemp(Candidate)); + } + + return Candidates; + } +} + +#undef LOCTEXT_NAMESPACE diff --git a/Source/AssetUsageAuditCore/Public/AssetDeletionScan.h b/Source/AssetUsageAuditCore/Public/AssetDeletionScan.h new file mode 100644 index 0000000..0ea74d5 --- /dev/null +++ b/Source/AssetUsageAuditCore/Public/AssetDeletionScan.h @@ -0,0 +1,151 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" +#include "UObject/TopLevelAssetPath.h" + +class IAssetRegistry; + +/** + * What would happen if this set of packages were deleted - worked out before anything is touched. + * + * Deletion is the one operation in this tool that destroys work, and the audit's own verdicts are + * not a safe basis for it on their own: Unknown means "the registry cannot see it", not "unused", + * and the whole point of the five-state verdict is that the tool never presents a guess as a fact. + * So the scan answers a narrower, checkable question - who still points at this, right now, in the + * registry - and leaves the judgement to the person reading it. + * + * Pure registry and filesystem work. It loads nothing, deletes nothing, and is deliberately + * separate from the code that performs the delete: the analysis has to be testable without an + * editor, and the actual removal is ObjectTools' job in the Editor module, not ours. + */ +namespace AssetDeletionScan +{ + /** + * Why a ticked package will not be deleted. + * + * Refusals are policy, not failures, and each one is shown with its reason. Silently dropping a + * row from the delete set would be worse than refusing loudly - the user ticked it and is owed + * an explanation. + */ + enum class ERefusal : uint8 + { + /** No objection. */ + None, + + /** + * The package is a map. + * + * Refused by decision, not by capability. A level is the unit the whole tool measures usage + * against, and deleting one silently invalidates every other row in the result. The engine + * also refuses to delete a level that is currently open, which would make the outcome depend + * on which map the user happens to have loaded. + */ + IsLevel, + + /** + * A One File Per Actor package - an actor or object belonging to some level. + * + * Deleting one is not deleting an asset, it is deleting a placed actor out of a map behind + * the level editor's back. That belongs in the level editor, with its undo. + */ + IsExternalPackage, + + /** /Engine, /Script or /Temp. Not this project's to delete. */ + NotProjectContent, + + /** The registry has no asset under this package name; nothing to delete. */ + Missing + }; + + ASSETUSAGEAUDITCORE_API const TCHAR* LexToString(ERefusal Refusal); + + /** One line of explanation, suitable for showing beside the row. */ + ASSETUSAGEAUDITCORE_API FText DescribeRefusal(ERefusal Refusal); + + /** One ticked package and what stands in the way of deleting it. */ + struct FCandidate + { + FName PackageName; + FName AssetName; + FTopLevelAssetPath ClassPath; + + /** Content/... form, as the report shows it. */ + FString PathFromProjectRoot; + + ERefusal Refusal = ERefusal::None; + + /** + * Packages that reference this one and are NOT themselves being deleted. + * + * The exclusion matters: deleting a Blueprint together with the mesh only it uses is a + * clean operation, and listing the Blueprint as a blocker would make every sensible + * multi-asset delete look dangerous. Truncated to FOptions::MaxReferencersListed - the list + * is there to be read, and forty names is not read. + */ + TArray OutsideReferencers; + + /** Full count before truncation. */ + int32 OutsideReferencerCount = 0; + + /** How many of those referencers are maps or OFPA packages - i.e. it is placed in a level. */ + int32 LevelReferencerCount = 0; + + /** + * The file exists on disk and is marked read-only. + * + * Worth surfacing here rather than discovering it as a failure afterwards: this project is + * Perforce-primary and unopened files are read-only by default, so a delete attempted + * without checking out first fails per-file, halfway through. + */ + bool bReadOnlyOnDisk = false; + + bool CanDelete() const + { + return Refusal == ERefusal::None; + } + }; + + struct FOptions + { + /** How many referencer names to keep per candidate. The count is always exact. */ + int32 MaxReferencersListed = 8; + }; + + // Exported: Summarise is defined out of line, so the Editor module cannot link without this. + struct ASSETUSAGEAUDITCORE_API FStats + { + int32 Requested = 0; + int32 Deletable = 0; + + int32 RefusedLevels = 0; + int32 RefusedExternal = 0; + int32 RefusedNotProjectContent = 0; + int32 RefusedMissing = 0; + + /** Deletable candidates that something outside the set still points at. */ + int32 WithOutsideReferencers = 0; + + /** Deletable candidates whose file is read-only on disk. */ + int32 ReadOnly = 0; + + FString Summarise() const; + }; + + /** + * Classify the packages and collect their outside referencers. + * + * Order is preserved, refusals included, so the caller can show the whole ticked set with a + * reason against each row instead of a shorter list that quietly lost entries. + * + * Referencers are queried with AssetUsageAudit::MakeTraversalQuery() for the same reason the + * audit uses it: a Hard-only query misses every soft reference, and a soft reference still + * breaks when its target disappears - it just breaks at runtime instead of at load. + */ + ASSETUSAGEAUDITCORE_API TArray Scan( + IAssetRegistry& Registry, + const TArray& Packages, + const FOptions& Options, + FStats& OutStats); +} diff --git a/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.cpp b/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.cpp new file mode 100644 index 0000000..f250386 --- /dev/null +++ b/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.cpp @@ -0,0 +1,669 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "SAssetDeleteDialog.h" + +#include "AssetDeletionScan.h" +#include "AssetRegistry/AssetRegistryModule.h" +#include "AssetRegistry/IAssetRegistry.h" +#include "AssetUsageAuditEditorModule.h" +#include "Framework/Application/SlateApplication.h" +#include "Framework/Docking/TabManager.h" +#include "ObjectTools.h" +#include "Styling/AppStyle.h" +#include "Styling/CoreStyle.h" +#include "Widgets/Input/SButton.h" +#include "Widgets/Input/SCheckBox.h" +#include "Widgets/Layout/SBorder.h" +#include "Widgets/Layout/SBox.h" +#include "Widgets/Layout/SSeparator.h" +#include "Widgets/SBoxPanel.h" +#include "Widgets/SWindow.h" +#include "Widgets/Text/STextBlock.h" +#include "Widgets/Views/SHeaderRow.h" +#include "Widgets/Views/SListView.h" + +#define LOCTEXT_NAMESPACE "AssetDeleteDialog" + +namespace DeleteColumns +{ + static const FName Check(TEXT("Check")); + static const FName Name(TEXT("Name")); + static const FName Type(TEXT("Type")); + static const FName Path(TEXT("Path")); + static const FName Referencers(TEXT("Referencers")); + static const FName Status(TEXT("Status")); +} + +namespace +{ + /** Red enough to read as a warning, light enough to stay legible on the dark editor theme. */ + const FLinearColor DangerColor(0.95f, 0.35f, 0.30f); + const FLinearColor CautionColor(0.95f, 0.72f, 0.25f); + const FLinearColor MutedColor(0.6f, 0.6f, 0.6f); +} + +/** One candidate plus its tick state. */ +struct FDeleteRow +{ + AssetDeletionScan::FCandidate Candidate; + + /** + * Ticked by default when nothing objects. + * + * The user already ticked these rows in the panel; re-ticking them here would be busywork. + * What the window adds is the chance to *un*tick, having now been shown the referencers. + */ + bool bChecked = false; +}; + +using FDeleteRowPtr = TSharedPtr; + +class SDeleteRowWidget : public SMultiColumnTableRow +{ +public: + SLATE_BEGIN_ARGS(SDeleteRowWidget) {} + SLATE_ARGUMENT(FDeleteRowPtr, Row) + SLATE_EVENT(FSimpleDelegate, OnCheckChanged) + SLATE_END_ARGS() + + void Construct(const FArguments& InArgs, const TSharedRef& InOwnerTable) + { + Row = InArgs._Row; + OnCheckChanged = InArgs._OnCheckChanged; + + SMultiColumnTableRow::Construct(FSuperRowType::FArguments(), InOwnerTable); + } + + virtual TSharedRef GenerateWidgetForColumn(const FName& ColumnName) override + { + if (!Row.IsValid()) + { + return SNullWidget::NullWidget; + } + + const AssetDeletionScan::FCandidate& Candidate = Row->Candidate; + const bool bCanDelete = Candidate.CanDelete(); + + if (ColumnName == DeleteColumns::Check) + { + return SNew(SBox) + .HAlign(HAlign_Center) + .VAlign(VAlign_Center) + [ + SNew(SCheckBox) + .IsEnabled(bCanDelete) + .IsChecked_Lambda([this]() + { + return Row->bChecked ? ECheckBoxState::Checked : ECheckBoxState::Unchecked; + }) + .OnCheckStateChanged_Lambda([this](ECheckBoxState NewState) + { + Row->bChecked = (NewState == ECheckBoxState::Checked); + OnCheckChanged.ExecuteIfBound(); + }) + ]; + } + + if (ColumnName == DeleteColumns::Name) + { + return SNew(STextBlock) + .Text(FText::FromName(Candidate.AssetName)) + .ColorAndOpacity(bCanDelete ? FSlateColor::UseForeground() : FSlateColor(MutedColor)) + .ToolTipText(FText::FromName(Candidate.PackageName)); + } + + if (ColumnName == DeleteColumns::Type) + { + return SNew(STextBlock) + .Text(FText::FromString(Candidate.ClassPath.GetAssetName().ToString())) + .ColorAndOpacity(FSlateColor(MutedColor)); + } + + if (ColumnName == DeleteColumns::Path) + { + return SNew(STextBlock) + .Text(FText::FromString(Candidate.PathFromProjectRoot)) + .ColorAndOpacity(FSlateColor(MutedColor)) + .ToolTipText(FText::FromString(Candidate.PathFromProjectRoot)); + } + + if (ColumnName == DeleteColumns::Referencers) + { + if (Candidate.OutsideReferencerCount == 0) + { + return SNew(STextBlock) + .Text(LOCTEXT("NoReferencers", "none")) + .ColorAndOpacity(FSlateColor(MutedColor)) + .ToolTipText(LOCTEXT("NoReferencersTip", "Nothing outside this delete set points at it, as far as the Asset Registry can see. Config files, C++ string paths and FMOD events are outside what it can see.")); + } + + // The names matter more than the number: "3" tells you to stop, the names tell you + // whether stopping was right. + FString Tooltip; + for (FName Referencer : Candidate.OutsideReferencers) + { + Tooltip += Referencer.ToString() + TEXT("\n"); + } + + if (Candidate.OutsideReferencerCount > Candidate.OutsideReferencers.Num()) + { + Tooltip += FString::Printf(TEXT("... and %d more"), + Candidate.OutsideReferencerCount - Candidate.OutsideReferencers.Num()); + } + + const FText Label = Candidate.LevelReferencerCount > 0 + ? FText::Format( + LOCTEXT("ReferencersWithLevels", "{0} ({1} on levels)"), + FText::AsNumber(Candidate.OutsideReferencerCount), + FText::AsNumber(Candidate.LevelReferencerCount)) + : FText::AsNumber(Candidate.OutsideReferencerCount); + + return SNew(STextBlock) + .Text(Label) + .ColorAndOpacity(FSlateColor(CautionColor)) + .ToolTipText(FText::FromString(Tooltip)); + } + + if (ColumnName == DeleteColumns::Status) + { + if (!bCanDelete) + { + return SNew(STextBlock) + .Text(AssetDeletionScan::DescribeRefusal(Candidate.Refusal)) + .ColorAndOpacity(FSlateColor(MutedColor)); + } + + if (Candidate.bReadOnlyOnDisk) + { + return SNew(STextBlock) + .Text(LOCTEXT("ReadOnly", "read-only on disk")) + .ColorAndOpacity(FSlateColor(CautionColor)) + .ToolTipText(LOCTEXT("ReadOnlyTip", "The file is not writable. This project is Perforce-primary, so an unopened file is read-only and the delete will fail on it. Check it out first.")); + } + + return SNew(STextBlock) + .Text(LOCTEXT("WillDelete", "will be deleted")) + .ColorAndOpacity(FSlateColor(DangerColor)); + } + + return SNullWidget::NullWidget; + } + +private: + FDeleteRowPtr Row; + FSimpleDelegate OnCheckChanged; +}; + +/** + * The window body. + * + * Owns the scan result and the tick state; deletes nothing until Delete is pressed, and reports + * back through the outcome struct rather than mutating anything the panel owns. + */ +class SAssetDeleteDialog : public SCompoundWidget +{ +public: + SLATE_BEGIN_ARGS(SAssetDeleteDialog) {} + SLATE_ARGUMENT(TArray, Packages) + SLATE_ARGUMENT(TSharedPtr, ParentWindow) + SLATE_END_ARGS() + + void Construct(const FArguments& InArgs) + { + ParentWindow = InArgs._ParentWindow; + + FAssetRegistryModule& RegistryModule = + FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")); + IAssetRegistry& Registry = RegistryModule.Get(); + + AssetDeletionScan::FOptions ScanOptions; + + const TArray Candidates = + AssetDeletionScan::Scan(Registry, InArgs._Packages, ScanOptions, Stats); + + for (const AssetDeletionScan::FCandidate& Candidate : Candidates) + { + FDeleteRowPtr NewRow = MakeShared(); + NewRow->Candidate = Candidate; + NewRow->bChecked = Candidate.CanDelete(); + Rows.Add(NewRow); + } + + UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Delete scan: %s"), *Stats.Summarise()); + + BuildHeaderRow(); + + ChildSlot + [ + SNew(SBorder) + .BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder")) + .Padding(12.0f) + [ + SNew(SVerticalBox) + + // --- The headline ------------------------------------------------------------ + // + // Stated as a consequence rather than as a question. "Delete 12 assets?" invites a + // reflex click; naming what disappears does not. + + SVerticalBox::Slot() + .AutoHeight() + .Padding(0.0f, 0.0f, 0.0f, 6.0f) + [ + SNew(STextBlock) + .Font(FCoreStyle::GetDefaultFontStyle("Bold", 13)) + .ColorAndOpacity(FSlateColor(DangerColor)) + .Text(FText::Format( + LOCTEXT("Headline", "{0} asset file(s) will be deleted from this project."), + FText::AsNumber(Stats.Deletable))) + ] + + + SVerticalBox::Slot() + .AutoHeight() + .Padding(0.0f, 0.0f, 0.0f, 8.0f) + [ + SNew(STextBlock) + .AutoWrapText(true) + .Text(LOCTEXT("Explanation", + "This removes the files from disk. It is not an export and there is no undo - what protects you is source control, so make sure this content is submitted or checked out before continuing.\n\n" + "Bear in mind what the audit cannot see: assets named from C++ or Blueprint strings, rows inside DataTables, and FMOD events are invisible to the Asset Registry. An asset with no referencers here is not proof that nothing uses it.")) + ] + + // --- Reference warning ------------------------------------------------------- + + SVerticalBox::Slot() + .AutoHeight() + .Padding(0.0f, 0.0f, 0.0f, 8.0f) + [ + SNew(SBorder) + .BorderImage(FAppStyle::GetBrush("ToolPanel.DarkGroupBorder")) + .Padding(8.0f) + .Visibility_Lambda([this]() + { + return Stats.WithOutsideReferencers > 0 ? EVisibility::Visible : EVisibility::Collapsed; + }) + [ + SNew(STextBlock) + .AutoWrapText(true) + .ColorAndOpacity(FSlateColor(CautionColor)) + .Text(FText::Format( + LOCTEXT("ReferencerWarning", + "{0} of them are still referenced by something outside this selection. Deleting those leaves the referencing assets pointing at nothing.\n" + "Unreal's own Delete Assets window opens next and lists every reference, where you can replace them with another asset or force the delete."), + FText::AsNumber(Stats.WithOutsideReferencers))) + ] + ] + + // --- Refusals ---------------------------------------------------------------- + // + // Shown, not hidden. The user ticked those rows; a delete that quietly acts on + // fewer assets than were ticked is how trust in a tool is lost. + + SVerticalBox::Slot() + .AutoHeight() + .Padding(0.0f, 0.0f, 0.0f, 8.0f) + [ + SNew(STextBlock) + .AutoWrapText(true) + .ColorAndOpacity(FSlateColor(MutedColor)) + .Visibility_Lambda([this]() + { + return (Stats.Requested - Stats.Deletable) > 0 ? EVisibility::Visible : EVisibility::Collapsed; + }) + .Text_Lambda([this]() { return GetRefusalText(); }) + ] + + + SVerticalBox::Slot() + .FillHeight(1.0f) + [ + SNew(SBox) + .MinDesiredHeight(240.0f) + [ + SAssignNew(ListView, SListView) + .ListItemsSource(&Rows) + .SelectionMode(ESelectionMode::None) + .HeaderRow(HeaderRow) + .OnGenerateRow_Lambda([this](FDeleteRowPtr Item, const TSharedRef& OwnerTable) + { + return SNew(SDeleteRowWidget, OwnerTable) + .Row(Item) + .OnCheckChanged(FSimpleDelegate::CreateLambda([this]() { Invalidate(EInvalidateWidgetReason::Paint); })); + }) + ] + ] + + + SVerticalBox::Slot() + .AutoHeight() + .Padding(0.0f, 8.0f) + [ + SNew(SSeparator) + ] + + // --- Acknowledgement --------------------------------------------------------- + // + // A deliberate second action. The Delete button stays disabled until this is + // ticked, which costs one click and removes the "I meant to press Cancel" class + // of accident entirely. + + SVerticalBox::Slot() + .AutoHeight() + .Padding(0.0f, 0.0f, 0.0f, 8.0f) + [ + SNew(SCheckBox) + .IsChecked_Lambda([this]() { return bAcknowledged ? ECheckBoxState::Checked : ECheckBoxState::Unchecked; }) + .OnCheckStateChanged_Lambda([this](ECheckBoxState NewState) { bAcknowledged = (NewState == ECheckBoxState::Checked); }) + [ + SNew(STextBlock) + .Margin(FMargin(4.0f, 0.0f, 0.0f, 0.0f)) + .Text(LOCTEXT("Acknowledge", "I understand these files will be removed from the project.")) + ] + ] + + + SVerticalBox::Slot() + .AutoHeight() + [ + SNew(SHorizontalBox) + + + SHorizontalBox::Slot() + .VAlign(VAlign_Center) + [ + SNew(STextBlock) + .ColorAndOpacity(FSlateColor(MutedColor)) + .Text_Lambda([this]() + { + return FText::Format( + LOCTEXT("Footer", "{0} ticked of {1} that can be deleted."), + FText::AsNumber(CountChecked()), + FText::AsNumber(Stats.Deletable)); + }) + ] + + + SHorizontalBox::Slot() + .AutoWidth() + .Padding(4.0f, 0.0f) + [ + SNew(SButton) + .Text(LOCTEXT("Cancel", "Cancel")) + .OnClicked(this, &SAssetDeleteDialog::OnCancel) + ] + + + SHorizontalBox::Slot() + .AutoWidth() + .Padding(4.0f, 0.0f) + [ + SNew(SButton) + .Text_Lambda([this]() + { + return FText::Format( + LOCTEXT("DeleteButton", "Delete {0} asset(s)"), + FText::AsNumber(CountChecked())); + }) + .ToolTipText(LOCTEXT("DeleteButtonTip", "Hand the ticked assets to Unreal's delete, which will show its own reference window before anything is removed.")) + .IsEnabled_Lambda([this]() { return bAcknowledged && CountChecked() > 0; }) + .OnClicked(this, &SAssetDeleteDialog::OnDelete) + ] + ] + ] + ]; + } + + bool WasAccepted() const + { + return bAccepted; + } + + /** Packages the user confirmed, in the order they were listed. */ + TArray GetConfirmedPackages() const + { + TArray Result; + + for (const FDeleteRowPtr& Row : Rows) + { + if (Row->bChecked && Row->Candidate.CanDelete()) + { + Result.Add(Row->Candidate.PackageName); + } + } + + return Result; + } + + const AssetDeletionScan::FStats& GetStats() const + { + return Stats; + } + +private: + void BuildHeaderRow() + { + HeaderRow = SNew(SHeaderRow) + + + SHeaderRow::Column(DeleteColumns::Check) + .DefaultLabel(FText::GetEmpty()) + .FixedWidth(28.0f) + [ + SNew(SBox) + .HAlign(HAlign_Center) + .VAlign(VAlign_Center) + [ + SNew(SCheckBox) + .IsChecked_Lambda([this]() { return GetHeaderCheckState(); }) + .OnCheckStateChanged_Lambda([this](ECheckBoxState NewState) { SetAllChecked(NewState == ECheckBoxState::Checked); }) + ] + ] + + + SHeaderRow::Column(DeleteColumns::Name) + .DefaultLabel(LOCTEXT("ColName", "Asset")) + .FillWidth(0.24f) + + + SHeaderRow::Column(DeleteColumns::Type) + .DefaultLabel(LOCTEXT("ColType", "Type")) + .FillWidth(0.13f) + + + SHeaderRow::Column(DeleteColumns::Path) + .DefaultLabel(LOCTEXT("ColPath", "Path")) + .FillWidth(0.33f) + + + SHeaderRow::Column(DeleteColumns::Referencers) + .DefaultLabel(LOCTEXT("ColReferencers", "Referenced by")) + .FillWidth(0.15f) + + + SHeaderRow::Column(DeleteColumns::Status) + .DefaultLabel(LOCTEXT("ColStatus", "Status")) + .FillWidth(0.15f); + } + + ECheckBoxState GetHeaderCheckState() const + { + const int32 Checked = CountChecked(); + + if (Checked == 0) + { + return ECheckBoxState::Unchecked; + } + + return Checked == Stats.Deletable ? ECheckBoxState::Checked : ECheckBoxState::Undetermined; + } + + void SetAllChecked(bool bNewChecked) + { + for (const FDeleteRowPtr& Row : Rows) + { + if (Row->Candidate.CanDelete()) + { + Row->bChecked = bNewChecked; + } + } + + if (ListView.IsValid()) + { + ListView->RequestListRefresh(); + } + } + + int32 CountChecked() const + { + int32 Count = 0; + + for (const FDeleteRowPtr& Row : Rows) + { + if (Row->bChecked && Row->Candidate.CanDelete()) + { + ++Count; + } + } + + return Count; + } + + FText GetRefusalText() const + { + TArray Parts; + + if (Stats.RefusedLevels > 0) + { + Parts.Add(FString::Printf(TEXT("%d level(s)"), Stats.RefusedLevels)); + } + + if (Stats.RefusedExternal > 0) + { + Parts.Add(FString::Printf(TEXT("%d actor package(s)"), Stats.RefusedExternal)); + } + + if (Stats.RefusedNotProjectContent > 0) + { + Parts.Add(FString::Printf(TEXT("%d outside project content"), Stats.RefusedNotProjectContent)); + } + + if (Stats.RefusedMissing > 0) + { + Parts.Add(FString::Printf(TEXT("%d already gone"), Stats.RefusedMissing)); + } + + return FText::Format( + LOCTEXT("RefusalSummary", "Kept out of the delete: {0}. They are listed below with a reason, and cannot be ticked."), + FText::FromString(FString::Join(Parts, TEXT(", ")))); + } + + FReply OnDelete() + { + bAccepted = true; + CloseWindow(); + return FReply::Handled(); + } + + FReply OnCancel() + { + bAccepted = false; + CloseWindow(); + return FReply::Handled(); + } + + void CloseWindow() + { + if (ParentWindow.IsValid()) + { + ParentWindow->RequestDestroyWindow(); + } + } + + TArray Rows; + AssetDeletionScan::FStats Stats; + + bool bAcknowledged = false; + bool bAccepted = false; + + TSharedPtr ParentWindow; + TSharedPtr> ListView; + TSharedPtr HeaderRow; +}; + +FAssetDeleteOutcome ShowAssetDeleteDialog(const TArray& Packages) +{ + FAssetDeleteOutcome Outcome; + Outcome.Requested = Packages.Num(); + + FAssetRegistryModule& RegistryModule = + FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")); + IAssetRegistry& Registry = RegistryModule.Get(); + + // The engine refuses to delete while the registry is still discovering, and does so with a + // transient notification that is easy to miss. Better to wait here than to have the delete + // quietly do nothing. + if (Registry.IsLoadingAssets()) + { + Registry.WaitForCompletion(); + } + + TSharedRef Window = SNew(SWindow) + .Title(LOCTEXT("WindowTitle", "Delete ticked assets")) + .ClientSize(FVector2D(940.0f, 620.0f)) + .SupportsMinimize(false) + .SupportsMaximize(false); + + TSharedRef Dialog = SNew(SAssetDeleteDialog) + .Packages(Packages) + .ParentWindow(Window); + + Window->SetContent(Dialog); + + FSlateApplication::Get().AddModalWindow(Window, FGlobalTabmanager::Get()->GetRootWindow()); + + Outcome.Refused = Dialog->GetStats().Requested - Dialog->GetStats().Deletable; + + if (!Dialog->WasAccepted()) + { + UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Delete cancelled; nothing was touched.")); + return Outcome; + } + + const TArray Confirmed = Dialog->GetConfirmedPackages(); + Outcome.bCancelled = false; + Outcome.Attempted = Confirmed.Num(); + + if (Confirmed.Num() == 0) + { + return Outcome; + } + + // Resolve to FAssetData at the last moment. The scan ran before the window opened and the + // user may have spent a while reading it; a stale FAssetData would ask the engine to delete + // something that has already moved. + TArray ToDelete; + + for (FName Package : Confirmed) + { + TArray Assets; + Registry.GetAssetsByPackageName(Package, Assets, /*bIncludeOnlyOnDiskAssets=*/true); + ToDelete.Append(Assets); + } + + if (ToDelete.Num() == 0) + { + UE_LOG(LogAssetUsageAuditEditor, Warning, + TEXT("Nothing was deleted: none of the %d confirmed packages still hold an asset."), Confirmed.Num()); + return Outcome; + } + + // The engine's own delete, with its confirmation left on. That window is where referencers are + // listed and where Force Delete and Replace References live - the behaviour the request asked + // to match, obtained by using it rather than by imitating it. + const int32 ObjectsDeleted = ObjectTools::DeleteAssets(ToDelete, /*bShowConfirmation=*/true); + + // Count packages, not objects: the engine returns deleted UObjects, and asking the registry + // what actually disappeared is the only figure that matches the Content Browser. + for (FName Package : Confirmed) + { + TArray Remaining; + Registry.GetAssetsByPackageName(Package, Remaining, /*bIncludeOnlyOnDiskAssets=*/true); + + if (Remaining.Num() == 0) + { + Outcome.DeletedPackages.Add(Package); + } + } + + UE_LOG(LogAssetUsageAuditEditor, Display, + TEXT("Delete finished: %d package(s) gone of %d confirmed (%d object(s) reported by the engine)."), + Outcome.DeletedPackages.Num(), Confirmed.Num(), ObjectsDeleted); + + return Outcome; +} + +#undef LOCTEXT_NAMESPACE diff --git a/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.h b/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.h new file mode 100644 index 0000000..b915617 --- /dev/null +++ b/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.h @@ -0,0 +1,46 @@ +// NextGenium 2026. Asset Usage Audit. + +#pragma once + +#include "CoreMinimal.h" + +/** What the delete window ended up doing. Everything the panel needs to report afterwards. */ +struct FAssetDeleteOutcome +{ + /** Packages handed to the window. */ + int32 Requested = 0; + + /** Refused by policy - levels, OFPA packages, engine content. Never reaches the engine. */ + int32 Refused = 0; + + /** Ticked and passed to ObjectTools. */ + int32 Attempted = 0; + + /** + * Packages the registry can no longer find afterwards. + * + * Measured rather than taken from the engine's return value: ObjectTools reports a count of + * deleted *objects*, and one package can hold several. Asking the registry what is actually + * gone is the only figure that matches what the user will see in the Content Browser. + */ + TArray DeletedPackages; + + /** True when the user closed or cancelled the window; nothing was touched. */ + bool bCancelled = true; +}; + +/** + * Show the delete window: what is about to be destroyed, what still points at it, and why some + * rows are refused. + * + * Modal, and deliberately heavier than the export window. Export is reversible by deleting the + * output folder; this is not, and the window's job is to be read rather than clicked through - + * hence the per-row referencer count, the explicit acknowledgement, and Cancel as the default. + * + * The window does not delete anything itself. Once confirmed it hands the assets to + * ObjectTools::DeleteAssets with the engine's own confirmation enabled, so the familiar Delete + * Assets dialog - referencer list, Force Delete, Replace References - appears exactly as it does + * from the Content Browser. Reimplementing that would mean reimplementing reference replacement, + * and getting it subtly wrong on someone's project. + */ +FAssetDeleteOutcome ShowAssetDeleteDialog(const TArray& Packages); diff --git a/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.cpp b/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.cpp index c74c8e9..e002199 100644 --- a/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.cpp +++ b/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.cpp @@ -12,6 +12,7 @@ #include "AssetUsageExporter.h" #include "AssetUsagePaths.h" #include "AssetUsageReportWriter.h" +#include "SAssetDeleteDialog.h" #include "AssetRegistry/AssetRegistryModule.h" #include "AssetRegistry/IAssetRegistry.h" #include "Algo/AllOf.h" @@ -580,6 +581,33 @@ void SAssetUsageAuditPanel::Construct(const FArguments& InArgs) .OnClicked(this, &SAssetUsageAuditPanel::OnExportFilesClicked) .IsEnabled(this, &SAssetUsageAuditPanel::HasCheckedAssets) ] + + // Delete sits last and behind a separator, deliberately. It acts on the same ticked + // set as Export, so it belongs on this bar, but it is the only irreversible action in + // the tool and must never be the button someone reaches by muscle memory. + + SHorizontalBox::Slot() + .AutoWidth() + .VAlign(VAlign_Center) + .Padding(8.0f, 2.0f, 2.0f, 2.0f) + [ + SNew(SSeparator) + .Orientation(Orient_Vertical) + ] + + + SHorizontalBox::Slot() + .AutoWidth() + .VAlign(VAlign_Center) + .Padding(2.0f, 0.0f) + [ + SNew(SButton) + .Text(LOCTEXT("DeleteTicked", "Delete ticked assets...")) + .ToolTipText(LOCTEXT("DeleteTickedTip", + "Permanently delete the ticked assets from the project. Levels are never deleted. " + "Shows what still references them before anything is removed.")) + .ButtonColorAndOpacity(FLinearColor(0.55f, 0.16f, 0.16f)) + .OnClicked(this, &SAssetUsageAuditPanel::OnDeleteClicked) + .IsEnabled(this, &SAssetUsageAuditPanel::HasCheckedAssets) + ] ] ]; } @@ -2224,6 +2252,83 @@ FReply SAssetUsageAuditPanel::OnExportFilesClicked() return FReply::Handled(); } +// --- Deleting ---------------------------------------------------------------------------------- + +FReply SAssetUsageAuditPanel::OnDeleteClicked() +{ + TArray Rows; + GetCheckedRows(Rows); + + if (Rows.IsEmpty()) + { + return FReply::Handled(); + } + + TArray Packages; + Packages.Reserve(Rows.Num()); + + for (const FAssetUsageRow& Row : Rows) + { + Packages.Add(Row.PackageName); + } + + // Deliberately NOT widened with the dependency closure, unlike export. Export widens so that a + // mesh arrives with its materials; widening a delete the same way would destroy content nobody + // ticked, and a single shared texture would drag half the project along with it. Delete acts on + // exactly what was ticked, and never on more. + const FAssetDeleteOutcome Outcome = ShowAssetDeleteDialog(Packages); + + if (Outcome.bCancelled) + { + StatusText = LOCTEXT("DeleteCancelled", "Delete cancelled - nothing was changed."); + return FReply::Handled(); + } + + ForgetDeletedPackages(Outcome.DeletedPackages); + + StatusText = FText::Format( + LOCTEXT("DeleteFinished", + "Deleted {0} of {1} ticked asset(s); {2} were refused. Reference counts elsewhere in this result are now out of date - run the audit again."), + FText::AsNumber(Outcome.DeletedPackages.Num()), + FText::AsNumber(Outcome.Requested), + FText::AsNumber(Outcome.Refused)); + + return FReply::Handled(); +} + +void SAssetUsageAuditPanel::ForgetDeletedPackages(const TArray& DeletedPackages) +{ + if (DeletedPackages.IsEmpty()) + { + return; + } + + const TSet Gone(DeletedPackages); + + LastResult.Rows.RemoveAll([&Gone](const FAssetUsageRow& Row) + { + return Gone.Contains(Row.PackageName); + }); + + AllRows.RemoveAll([&Gone](const FAssetUsageRowPtr& Row) + { + return Row.IsValid() && Gone.Contains(Row->PackageName); + }); + + for (FName Package : DeletedPackages) + { + CheckedPackages.Remove(Package); + } + + // Removing the rows is honest but incomplete: everything that referenced them still carries the + // reference counts it had before, and an asset that was UsedByAssetsOnly through a now-deleted + // Blueprint has a different verdict than the one on screen. Marking the result stale says so, + // rather than leaving figures that look freshly measured. + bResultStale = true; + + RebuildFilteredRows(); +} + void SAssetUsageAuditPanel::RunMigrateExport(const TArray& Rows, const FAssetExportRequest& Request) { TArray Packages; diff --git a/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.h b/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.h index ab125f5..4462fec 100644 --- a/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.h +++ b/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.h @@ -278,6 +278,31 @@ private: FReply OnExportFilesClicked(); bool HasCheckedAssets() const; + // --- Deleting ---------------------------------------------------------------------------- + + /** + * Delete the ticked assets, after two confirmations. + * + * Sits beside Export because it acts on the same ticked set, and is the reason the audit is + * worth running: the point of finding unused content is to be able to remove it. It is kept + * visually apart and never becomes the default action. + * + * Levels are refused outright - see AssetDeletionScan::ERefusal::IsLevel. Everything else the + * window explains before anything is touched, and the destruction itself is ObjectTools' code, + * not ours. + */ + FReply OnDeleteClicked(); + + /** + * Drop deleted packages from the result without re-running the sweep. + * + * A full re-run after a delete would cost minutes on this project, and the rows that remain are + * still correct - what changed is that some assets are gone, plus the referencer counts of + * whatever pointed at them. The result is marked stale so the numbers are not mistaken for a + * fresh sweep. + */ + void ForgetDeletedPackages(const TArray& DeletedPackages); + /** Byte copy of the package files, expanding levels to their OFPA packages. */ void RunCopyExport(const TArray& Rows, const FAssetExportRequest& Request); diff --git a/Source/AssetUsageAuditTests/Private/AssetDeletionScan.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetDeletionScan.spec.cpp new file mode 100644 index 0000000..a5ac415 --- /dev/null +++ b/Source/AssetUsageAuditTests/Private/AssetDeletionScan.spec.cpp @@ -0,0 +1,449 @@ +// NextGenium 2026. Asset Usage Audit. + +#include "AssetDeletionScan.h" + +#include "AssetRegistry/ARFilter.h" +#include "AssetRegistry/IAssetRegistry.h" +#include "AssetUsageAuditTypes.h" +#include "AssetUsagePaths.h" +#include "Engine/World.h" +#include "HAL/FileManager.h" +#include "Misc/AutomationTest.h" +#include "Misc/PackageName.h" + +#if WITH_DEV_AUTOMATION_TESTS + +/** + * The refusals, and the referencer arithmetic behind the warning. + * + * This is the only part of the tool that leads to something being destroyed, so the tests are + * written against the two claims the delete window makes to the user: "these will not be touched", + * and "this many things still point at that". Both are decided here; the window only renders them. + * + * Nothing in this spec deletes anything. AssetDeletionScan cannot - it holds no delete code at all, + * which is exactly why the analysis was split out of the Editor module. + */ +BEGIN_DEFINE_SPEC(AssetDeletionScanSpec, + "AssetUsageAudit.DeletionScan", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + + static IAssetRegistry& ReadyRegistry() + { + IAssetRegistry& Registry = IAssetRegistry::GetChecked(); + if (Registry.IsLoadingAssets()) + { + Registry.WaitForCompletion(); + } + return Registry; + } + + /** First map in /Game, chosen deterministically so a failure is reproducible. */ + static FName FindLevelPackage() + { + IAssetRegistry& Registry = ReadyRegistry(); + + FARFilter Filter; + Filter.PackagePaths.Add(FName(TEXT("/Game"))); + Filter.bRecursivePaths = true; + Filter.bIncludeOnlyOnDiskAssets = true; + Filter.ClassPaths.Add(UWorld::StaticClass()->GetClassPathName()); + + TArray Levels; + Registry.EnumerateAssets(Filter, [&Levels](const FAssetData& AssetData) + { + Levels.Add(AssetData.PackageName); + return Levels.Num() < 512; + }); + + if (Levels.IsEmpty()) + { + return NAME_None; + } + + Levels.Sort(FNameLexicalLess()); + return Levels[0]; + } + + /** First One File Per Actor package, if this project has any. */ + static FName FindExternalPackage() + { + IAssetRegistry& Registry = ReadyRegistry(); + + FARFilter Filter; + Filter.PackagePaths.Add(FName(TEXT("/Game/__ExternalActors__"))); + Filter.bRecursivePaths = true; + Filter.bIncludeOnlyOnDiskAssets = true; + + TArray Found; + Registry.EnumerateAssets(Filter, [&Found](const FAssetData& AssetData) + { + Found.Add(AssetData.PackageName); + return false; + }); + + return Found.IsEmpty() ? NAME_None : Found[0]; + } + + /** + * An ordinary asset together with one package that references it. + * + * Both halves are needed to prove the delete-set exclusion: the referencer has to be a real one + * for "referenced from outside" and "referenced from inside the set" to be the same edge seen + * twice, which is the whole point of the check. + */ + static FName FindReferencedAsset(FName& OutReferencer) + { + IAssetRegistry& Registry = ReadyRegistry(); + + FARFilter Filter; + Filter.PackagePaths.Add(FName(TEXT("/Game"))); + Filter.bRecursivePaths = true; + Filter.bIncludeOnlyOnDiskAssets = true; + + TArray Candidates; + Registry.EnumerateAssets(Filter, [&Candidates](const FAssetData& AssetData) + { + if (AssetData.IsRedirector() || AssetData.AssetClassPath == UWorld::StaticClass()->GetClassPathName()) + { + return true; + } + + if (AssetUsagePaths::IsExternalPackage(AssetData.PackageName)) + { + return true; + } + + Candidates.Add(AssetData.PackageName); + return Candidates.Num() < 4096; + }); + + Candidates.Sort(FNameLexicalLess()); + + TArray Referencers; + + for (FName Candidate : Candidates) + { + Referencers.Reset(); + Registry.GetReferencers(Candidate, Referencers, + AssetUsageAudit::MakeTraversalCategory(), AssetUsageAudit::MakeTraversalQuery()); + + for (FName Referencer : Referencers) + { + if (Referencer != Candidate) + { + OutReferencer = Referencer; + return Candidate; + } + } + } + + return NAME_None; + } + +END_DEFINE_SPEC(AssetDeletionScanSpec) + +void AssetDeletionScanSpec::Define() +{ + Describe("Refusing what must not be deleted", [this]() + { + It("refuses a map", [this]() + { + const FName Level = FindLevelPackage(); + + if (Level.IsNone()) + { + AddWarning(TEXT("This project has no maps; skipping.")); + return; + } + + AssetDeletionScan::FStats Stats; + const TArray Candidates = + AssetDeletionScan::Scan(ReadyRegistry(), { Level }, AssetDeletionScan::FOptions(), Stats); + + if (Candidates.Num() != 1) + { + AddError(TEXT("expected exactly one candidate")); + return; + } + + // The user's rule, and the one refusal that is policy rather than capability: the engine + // would happily delete an unopened map. + TestEqual(TEXT("refused as a level"), + Candidates[0].Refusal, AssetDeletionScan::ERefusal::IsLevel); + TestFalse(TEXT("not deletable"), Candidates[0].CanDelete()); + TestEqual(TEXT("counted as a refused level"), Stats.RefusedLevels, 1); + TestEqual(TEXT("nothing deletable"), Stats.Deletable, 0); + }); + + It("refuses a One File Per Actor package", [this]() + { + const FName External = FindExternalPackage(); + + if (External.IsNone()) + { + AddWarning(TEXT("This project has no external actor packages; skipping.")); + return; + } + + AssetDeletionScan::FStats Stats; + const TArray Candidates = + AssetDeletionScan::Scan(ReadyRegistry(), { External }, AssetDeletionScan::FOptions(), Stats); + + if (Candidates.Num() != 1) + { + AddError(TEXT("expected exactly one candidate")); + return; + } + + // Deleting one of these is removing a placed actor from a map behind the level editor's + // back - no undo, and the map keeps a reference to a package that no longer exists. + TestEqual(TEXT("refused as external"), + Candidates[0].Refusal, AssetDeletionScan::ERefusal::IsExternalPackage); + TestEqual(TEXT("counted"), Stats.RefusedExternal, 1); + }); + + It("refuses engine content", [this]() + { + AssetDeletionScan::FStats Stats; + const TArray Candidates = AssetDeletionScan::Scan( + ReadyRegistry(), + { FName(TEXT("/Engine/BasicShapes/Cube")) }, + AssetDeletionScan::FOptions(), + Stats); + + if (Candidates.Num() != 1) + { + AddError(TEXT("expected exactly one candidate")); + return; + } + + TestEqual(TEXT("refused as engine content"), + Candidates[0].Refusal, AssetDeletionScan::ERefusal::NotProjectContent); + TestEqual(TEXT("counted"), Stats.RefusedNotProjectContent, 1); + }); + + It("refuses a package the registry does not know", [this]() + { + AssetDeletionScan::FStats Stats; + const TArray Candidates = AssetDeletionScan::Scan( + ReadyRegistry(), + { FName(TEXT("/Game/AssetUsageAuditNoSuchPackage")) }, + AssetDeletionScan::FOptions(), + Stats); + + if (Candidates.Num() != 1) + { + AddError(TEXT("expected exactly one candidate")); + return; + } + + TestEqual(TEXT("reported as missing"), + Candidates[0].Refusal, AssetDeletionScan::ERefusal::Missing); + TestEqual(TEXT("counted"), Stats.RefusedMissing, 1); + }); + + It("keeps refused rows in the result rather than dropping them", [this]() + { + const FName Level = FindLevelPackage(); + + if (Level.IsNone()) + { + AddWarning(TEXT("This project has no maps; skipping.")); + return; + } + + const FName Missing(TEXT("/Game/AssetUsageAuditNoSuchPackage")); + const FName Engine(TEXT("/Engine/BasicShapes/Cube")); + + AssetDeletionScan::FStats Stats; + const TArray Candidates = AssetDeletionScan::Scan( + ReadyRegistry(), { Level, Missing, Engine }, AssetDeletionScan::FOptions(), Stats); + + // A refusal the user cannot see is indistinguishable from the tool losing their + // selection, so every ticked package must come back with a reason attached. + TestEqual(TEXT("all three returned"), Candidates.Num(), 3); + TestEqual(TEXT("in the original order"), Candidates[0].PackageName, Level); + TestEqual(TEXT("in the original order"), Candidates[1].PackageName, Missing); + TestEqual(TEXT("in the original order"), Candidates[2].PackageName, Engine); + + TestEqual(TEXT("requested count matches"), Stats.Requested, 3); + TestEqual(TEXT("none deletable"), Stats.Deletable, 0); + }); + }); + + Describe("Counting what still points at the asset", [this]() + { + It("lists a referencer that is not itself being deleted", [this]() + { + FName Referencer = NAME_None; + const FName Asset = FindReferencedAsset(Referencer); + + if (Asset.IsNone()) + { + AddWarning(TEXT("No referenced asset was found; skipping.")); + return; + } + + AssetDeletionScan::FStats Stats; + const TArray Candidates = + AssetDeletionScan::Scan(ReadyRegistry(), { Asset }, AssetDeletionScan::FOptions(), Stats); + + if (Candidates.Num() != 1 || !Candidates[0].CanDelete()) + { + AddError(TEXT("expected one deletable candidate")); + return; + } + + TestTrue(TEXT("at least one outside referencer"), Candidates[0].OutsideReferencerCount > 0); + TestTrue(TEXT("the known referencer is listed"), + Candidates[0].OutsideReferencers.Contains(Referencer) + || Candidates[0].OutsideReferencerCount > Candidates[0].OutsideReferencers.Num()); + TestEqual(TEXT("counted in the stats"), Stats.WithOutsideReferencers, 1); + }); + + It("does not count a referencer that is in the delete set", [this]() + { + FName Referencer = NAME_None; + const FName Asset = FindReferencedAsset(Referencer); + + if (Asset.IsNone() || Referencer.IsNone()) + { + AddWarning(TEXT("No referenced asset was found; skipping.")); + return; + } + + AssetDeletionScan::FStats Before; + const TArray Alone = + AssetDeletionScan::Scan(ReadyRegistry(), { Asset }, AssetDeletionScan::FOptions(), Before); + + AssetDeletionScan::FStats After; + const TArray Together = + AssetDeletionScan::Scan(ReadyRegistry(), { Asset, Referencer }, AssetDeletionScan::FOptions(), After); + + const AssetDeletionScan::FCandidate* Widened = + Together.FindByPredicate([Asset](const AssetDeletionScan::FCandidate& C) { return C.PackageName == Asset; }); + + if (Alone.Num() != 1 || !Widened) + { + AddError(TEXT("expected the asset in both scans")); + return; + } + + // Deleting a Blueprint together with the mesh only it uses is a clean operation. If the + // Blueprint were still counted, every sensible multi-asset delete would look dangerous + // and the warning would stop meaning anything. + TestTrue(TEXT("the count dropped once its referencer joined the set"), + Widened->OutsideReferencerCount < Alone[0].OutsideReferencerCount); + + TestFalse(TEXT("the referencer is no longer listed"), + Widened->OutsideReferencers.Contains(Referencer)); + }); + + It("truncates the listed names but not the count", [this]() + { + FName Referencer = NAME_None; + const FName Asset = FindReferencedAsset(Referencer); + + if (Asset.IsNone()) + { + AddWarning(TEXT("No referenced asset was found; skipping.")); + return; + } + + AssetDeletionScan::FOptions Options; + Options.MaxReferencersListed = 1; + + AssetDeletionScan::FStats Stats; + const TArray Candidates = + AssetDeletionScan::Scan(ReadyRegistry(), { Asset }, Options, Stats); + + if (Candidates.Num() != 1) + { + AddError(TEXT("expected one candidate")); + return; + } + + TestTrue(TEXT("at most one name kept"), Candidates[0].OutsideReferencers.Num() <= 1); + TestTrue(TEXT("the count is not truncated with it"), + Candidates[0].OutsideReferencerCount >= Candidates[0].OutsideReferencers.Num()); + }); + }); + + Describe("Describing a deletable asset", [this]() + { + It("reports the read-only flag the way the filesystem does", [this]() + { + FName Referencer = NAME_None; + const FName Asset = FindReferencedAsset(Referencer); + + if (Asset.IsNone()) + { + AddWarning(TEXT("No suitable asset was found; skipping.")); + return; + } + + AssetDeletionScan::FStats Stats; + const TArray Candidates = + AssetDeletionScan::Scan(ReadyRegistry(), { Asset }, AssetDeletionScan::FOptions(), Stats); + + if (Candidates.Num() != 1 || !Candidates[0].CanDelete()) + { + AddError(TEXT("expected one deletable candidate")); + return; + } + + FString Filename; + if (!FPackageName::DoesPackageExist(Asset.ToString(), &Filename)) + { + AddError(TEXT("the package has no file on disk")); + return; + } + + // This project is Perforce-primary, so read-only is the normal state of an unopened + // file. Reporting it up front is the difference between a warning and a delete that + // fails halfway through the selection. + TestEqual(TEXT("matches the filesystem"), + Candidates[0].bReadOnlyOnDisk, IFileManager::Get().IsReadOnly(*Filename)); + + TestEqual(TEXT("read-only count agrees"), + Stats.ReadOnly, Candidates[0].bReadOnlyOnDisk ? 1 : 0); + + AddInfo(FString::Printf(TEXT("%s: %d outside referencer(s), %d from levels, read-only %s."), + *Asset.ToString(), + Candidates[0].OutsideReferencerCount, + Candidates[0].LevelReferencerCount, + Candidates[0].bReadOnlyOnDisk ? TEXT("yes") : TEXT("no"))); + }); + + It("fills in the name, class and project-relative path", [this]() + { + FName Referencer = NAME_None; + const FName Asset = FindReferencedAsset(Referencer); + + if (Asset.IsNone()) + { + AddWarning(TEXT("No suitable asset was found; skipping.")); + return; + } + + AssetDeletionScan::FStats Stats; + const TArray Candidates = + AssetDeletionScan::Scan(ReadyRegistry(), { Asset }, AssetDeletionScan::FOptions(), Stats); + + if (Candidates.Num() != 1) + { + AddError(TEXT("expected one candidate")); + return; + } + + // The window shows these three and nothing else per row, so an empty one is a blank + // line in front of someone about to press Delete. + TestFalse(TEXT("name filled"), Candidates[0].AssetName.IsNone()); + TestTrue(TEXT("class filled"), Candidates[0].ClassPath.IsValid()); + TestTrue(TEXT("path starts at Content/"), + Candidates[0].PathFromProjectRoot.StartsWith(TEXT("Content/"))); + }); + }); +} + +#endif // WITH_DEV_AUTOMATION_TESTS From f3c82d3815376f9dd9a2f8415463ee360f3eae81 Mon Sep 17 00:00:00 2001 From: MagentaDolphin Date: Mon, 7 Sep 2026 17:35:50 +0700 Subject: [PATCH 2/8] fix: correct authorship attribution in deletion feature files Co-Authored-By: Claude Code --- Source/AssetUsageAuditCore/Private/AssetDeletionScan.cpp | 2 +- Source/AssetUsageAuditCore/Public/AssetDeletionScan.h | 2 +- Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.cpp | 2 +- Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.h | 2 +- Source/AssetUsageAuditTests/Private/AssetDeletionScan.spec.cpp | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Source/AssetUsageAuditCore/Private/AssetDeletionScan.cpp b/Source/AssetUsageAuditCore/Private/AssetDeletionScan.cpp index 812a20d..a0a3bc5 100644 --- a/Source/AssetUsageAuditCore/Private/AssetDeletionScan.cpp +++ b/Source/AssetUsageAuditCore/Private/AssetDeletionScan.cpp @@ -1,4 +1,4 @@ -// NextGenium 2026. Asset Usage Audit. +// MagentaDolphin 2026. Asset Usage Audit. #include "AssetDeletionScan.h" diff --git a/Source/AssetUsageAuditCore/Public/AssetDeletionScan.h b/Source/AssetUsageAuditCore/Public/AssetDeletionScan.h index 0ea74d5..fd32cfe 100644 --- a/Source/AssetUsageAuditCore/Public/AssetDeletionScan.h +++ b/Source/AssetUsageAuditCore/Public/AssetDeletionScan.h @@ -1,4 +1,4 @@ -// NextGenium 2026. Asset Usage Audit. +// MagentaDolphin 2026. Asset Usage Audit. #pragma once diff --git a/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.cpp b/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.cpp index f250386..54b6253 100644 --- a/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.cpp +++ b/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.cpp @@ -1,4 +1,4 @@ -// NextGenium 2026. Asset Usage Audit. +// MagentaDolphin 2026. Asset Usage Audit. #include "SAssetDeleteDialog.h" diff --git a/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.h b/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.h index b915617..b799cb7 100644 --- a/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.h +++ b/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.h @@ -1,4 +1,4 @@ -// NextGenium 2026. Asset Usage Audit. +// MagentaDolphin 2026. Asset Usage Audit. #pragma once diff --git a/Source/AssetUsageAuditTests/Private/AssetDeletionScan.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetDeletionScan.spec.cpp index a5ac415..2e862af 100644 --- a/Source/AssetUsageAuditTests/Private/AssetDeletionScan.spec.cpp +++ b/Source/AssetUsageAuditTests/Private/AssetDeletionScan.spec.cpp @@ -1,4 +1,4 @@ -// NextGenium 2026. Asset Usage Audit. +// MagentaDolphin 2026. Asset Usage Audit. #include "AssetDeletionScan.h" From 0363b07b653a6ae5c99e5c330ce7e38ec5dd8fdf Mon Sep 17 00:00:00 2001 From: MagentaDolphin Date: Mon, 7 Sep 2026 17:44:01 +0700 Subject: [PATCH 3/8] docs: remove studio-specific paths and references from README Generalises the settings path, build command, console example and the read-only rationale; drops internal knowledge-base links and team roles. Co-Authored-By: Claude Code --- README.md | 41 +++++++++++++++-------------------------- 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 5ec55b5..a36bb89 100644 --- a/README.md +++ b/README.md @@ -71,13 +71,13 @@ Asset Registry принципиально не видит: **Инструмент никогда не удаляет и не предлагает удалить.** Только отбор и выгрузка. -Смягчение: скан `Config/` и `Source/` по `\/Game([A-Za-z0-9_.\/]+)\b`. Без него `BP_FirstPersonGameMode`, `BP_MenuSystemGameInstance` и `RefinedMenuMap` помечались бы мусором — на них не ссылается ни один ассет, только `DefaultEngine.ini`. +Смягчение: скан `Config/` и `Source/` по `\/Game([A-Za-z0-9_.\/]+)\b`. Без него GameMode, GameInstance и стартовая карта помечались бы мусором — на них не ссылается ни один ассет, только `DefaultEngine.ini`. --- ## Настройки -`Project Settings → 14Overmind → Asset Usage Audit`. Пишутся в `Config/DefaultEditor.ini` — файл Perforce-трекается, настройки видны команде. +`Project Settings → <Проект> → Asset Usage Audit`. Пишутся в `Config/DefaultEditor.ini` — файл под контролем версий, настройки общие для команды. Состояние панели (выбранный уровень, типы, режим выгрузки, чекбокс зависимостей) хранится **отдельно** — в `Saved/Config/.../EditorPerProjectUserSettings.ini`, пер-юзерно и вне контроля версий. Сохраняется при закрытии вкладки; аварийное завершение редактора теряет изменения. Строка поиска намеренно не восстанавливается: панель, открывшаяся пустой из-за забытого фильтра, выглядит сломанной. @@ -92,7 +92,7 @@ Asset Registry принципиально не видит: | `bMirrorFolderStructure` | `false` → плоская папка | | `ExchangeFormatByClass` | пусто → встроенные умолчания | -⚠️ `Content/MenuSystemPro` **намеренно не исключён**, хотя это покупной пак: он поставляет `BP_MenuSystemGameInstance`, который `DefaultEngine.ini` назначает GameInstance проекта. +⚠️ Покупной пак **не следует исключать вслепую**: он может поставлять класс, который `DefaultEngine.ini` назначает GameMode или GameInstance проекта. Проверяйте `ExcludedPackagePaths` против конфига. --- @@ -100,7 +100,7 @@ Asset Registry принципиально не видит: ``` AssetUsageAudit.OpenPanel -AssetUsageAudit.Run [/Game/Space/Maps/WP_Main ...] +AssetUsageAudit.Run [/Game/Maps/YourLevel ...] AssetUsageAudit.FindUnused ``` @@ -196,7 +196,7 @@ BOM обязателен: без него Excel ломает кириллицу. Побайтовое копирование, ничего не загружается. Уровень тянет за собой свои OFPA-пакеты — без них выгруженная карта откроется пустой. -⚠️ **С источника снимается атрибут read-only.** Проект Perforce-primary, неоткрытые файлы на диске read-only, а Windows `CopyFile` переносит атрибут на копию. Без этого артист получал бы нередактируемую папку, а повторный прогон с политикой `Overwrite` падал бы на собственном предыдущем выводе. Флаг снимается с обеих сторон: перед перезаписью и после копирования. +⚠️ **С источника снимается атрибут read-only.** В проектах под VCS, которая держит невытянутые файлы read-only (Perforce и подобные), Windows `CopyFile` переносит атрибут на копию. Без этого артист получал бы нередактируемую папку, а повторный прогон с политикой `Overwrite` падал бы на собственном предыдущем выводе. Флаг снимается с обеих сторон: перед перезаписью и после копирования. ### Конвертация в обменные форматы @@ -257,7 +257,7 @@ BOM обязателен: без него Excel ломает кириллицу. - Ссылающиеся ассеты, которые сами удаляются вместе с целью, **в счётчик не попадают**. Иначе удаление блюпринта вместе с его единственным мешем выглядело бы опасным, хотя оно чистое. - Референсеры запрашиваются тем же `NoRequirements`-запросом, что и аудит: hard-only потерял бы каждую soft-ссылку, а soft-ссылка ломается точно так же — просто в рантайме, а не при загрузке. -- Файл, помеченный **read-only на диске**, отмечается заранее. Проект Perforce-primary, невытянутый файл нередактируемый, и без этой пометки удаление падало бы по одному файлу в середине пачки. +- Файл, помеченный **read-only на диске**, отмечается заранее. Под VCS, которая держит невытянутые файлы нередактируемыми, без этой пометки удаление падало бы по одному файлу в середине пачки. - Кнопка удаления неактивна, пока не поставлена галочка «I understand these files will be removed from the project». ### Окно 2 — родное от UE @@ -373,9 +373,9 @@ WP_Main → BP_FirstPersonGameMode → PDA_MenuSystemConfig → L_MainLevel → ⚠️ Закрыть редактор — иначе линковка DLL упадёт. ```bash -"C:/Program Files/Epic Games/UE_5.6/Engine/Build/BatchFiles/Build.bat" \ - SpaceEditor Win64 Development \ - -project="D:\Work\NG\ng_MagentaDolphin_space\overmind\14Overmind.uproject" -waitmutex +"/Engine/Build/BatchFiles/Build.bat" \ + Editor Win64 Development \ + -project="/.uproject" -waitmutex ``` ```bash @@ -438,34 +438,23 @@ Reference Viewer сам по себе не источник истины: он - **UI после переделки в дерево руками не проверялся.** Компилируется, 121 автотест зелёный, но автотесты не трогают Slate: раскрытие уровней, третье состояние галочки на заголовке и сортировка внутри группы проверены только чтением кода. - **Коммандлет не написан.** Упирается в дефект headless выше. - **Конвертация не прогонялась на большом объёме.** Тесты покрывают единицы ассетов; поведение GC и времени на тысячах — не замерено. -- **Карта форматов узкая.** В дефолте только классы, для которых движок реально поставляет `UExporter`. Материалы, блюпринты и Niagara не конвертируются никуда — это ограничение движка, а не недоделка, но LA/3D нужно об этом сказать явно. -- `BP_FirstPersonGameMode` даёт 29% строк `WP_Main` — нужен ли фильтр «исключить достижимое только через GameMode», решать LA/3D. -- FMOD в `Unknown` — скрывать по умолчанию или помечать, решать аудио-отделу. -- Плагин лежит в git-части репозитория (`Plugins/**` вайтлистится в `.gitignore` и исключается в `.p4ignore`). Вынос в сабмодуль — после согласования. +- **Карта форматов узкая.** В дефолте только классы, для которых движок реально поставляет `UExporter`. Материалы, блюпринты и Niagara не конвертируются никуда — это ограничение движка, а не недоделка, но пользователя нужно об этом предупредить явно. +- GameMode может давать заметную долю строк отчёта по главной карте — нужен ли фильтр «исключить достижимое только через GameMode», решать по месту. +- Ассеты сторонних аудио-плагинов попадают в `Unknown` — скрывать по умолчанию или помечать, решать по месту. --- ## Контроль версий -Плагин — **самостоятельный git-репозиторий** внутри `Plugins/`, как остальные 26 плагинов проекта. Удалённого сервера пока нет: репозиторий и выделенный remote ещё не согласованы. +Плагин — **самостоятельный git-репозиторий**. Артефакты сборки (`Binaries/`, `Intermediate/`) исключены локальным `.gitignore`: они пересобираются UBT из исходников и в коммит попадать не должны. -Из этого следуют две вещи: - -- в `.gitmodules` записи **нет** и быть не должно, пока нет URL. Запись без рабочего адреса уронит `git submodule update --init` в `SetupProject.bat` у всех, кто дёрнет проект; -- родительский репозиторий видит папку как `?? Plugins/AssetUsageAudit/`. Так и задумано: коммитить её содержимое в родителя нельзя, иначе при появлении сабмодуля будут две копии одних файлов. - -**История живёт только на машине разработчика.** Как только появится remote: +При подключении к проекту как сабмодуль: ```bash -git -C Plugins/AssetUsageAudit remote add origin -git -C Plugins/AssetUsageAudit push -u origin master git submodule add Plugins/AssetUsageAudit ``` -Артефакты сборки (`Binaries/`, `Intermediate/` — 231 МБ) исключены локальным `.gitignore`. В Perforce плагин не попадает: `Plugins/**` исключён в `.p4ignore`. ## Связанное -- `.docs/architecture/asset-usage-audit.md` — заметка в базе знаний -- `.docs/guides/build-and-run.md` — сборка и прогон тестов -- `Plugins/AssetsCleaner` — уже включён, ищет неиспользуемые ассеты, но **не умеет привязку к уровням** +- `Plugins/AssetsCleaner` — ищет неиспользуемые ассеты, но **не умеет привязку к уровням** From e92306c79750006dd1ecc1a8fd0c39e11d962832 Mon Sep 17 00:00:00 2001 From: MagentaDolphin Date: Mon, 7 Sep 2026 17:44:44 +0700 Subject: [PATCH 4/8] docs: anonymise project asset names in examples Co-Authored-By: Claude Code --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a36bb89..d4a9660 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ **Route** — цепочка, объясняющая вердикт: ``` -WP_Main → BP_Pickup_Child_C_UAID_...1381106233 → BP_Pickup_Master → SM_ReaperStation +WP_Example → BP_Child_C_UAID_... → BP_Master → SM_Station ``` Вердикт без маршрута для художника бесполезен, поэтому маршрут пишется всегда. @@ -328,10 +328,10 @@ Hard/soft — это **колонка в отчёте**, а не фильтр з ### 2. Границу карты пересекать только от уровня или его внешнего пакета -Первая версия проваливалась в любой встреченный World. Замер на `WP_Main`: **18 136** строк, из них **9 994** приходили через чужую карту: +Первая версия проваливалась в любой встреченный World. Замер на `WP_Example`: **18 136** строк, из них **9 994** приходили через чужую карту: ``` -WP_Main → BP_FirstPersonGameMode → PDA_MenuSystemConfig → L_MainLevel → … +WP_Example → BP_GameMode → PDA_MenuConfig → L_Other → … ``` Две трети ответа были содержимым другого уровня. После исправления — **8 644**. @@ -388,7 +388,7 @@ WP_Main → BP_FirstPersonGameMode → PDA_MenuSystemConfig → L_MainLevel → **128 спек**, префикс `AssetUsageAudit.*`. -Три набора работают на **настоящем контенте проекта**, а не на выдуманных именах: `ExporterLive` (копирование, коллизии, раскрытие OFPA), `ExchangeExport` (конвертация) и `GraphFidelity` (сверка графа с реестром). Субъект они ищут через Asset Registry и берут **самый маленький** OFPA-уровень — на `WP_Main` тест копировал бы гигабайты. Если контент не найден, тест пишет предупреждение и не падает, поэтому в логе стоит смотреть на предупреждения: их отсутствие означает, что тесты реально работали с контентом. +Три набора работают на **настоящем контенте проекта**, а не на выдуманных именах: `ExporterLive` (копирование, коллизии, раскрытие OFPA), `ExchangeExport` (конвертация) и `GraphFidelity` (сверка графа с реестром). Субъект они ищут через Asset Registry и берут **самый маленький** OFPA-уровень — на `WP_Example` тест копировал бы гигабайты. Если контент не найден, тест пишет предупреждение и не падает, поэтому в логе стоит смотреть на предупреждения: их отсутствие означает, что тесты реально работали с контентом. ### `GraphFidelity` — замена ручной сверке с Reference Viewer @@ -425,7 +425,7 @@ Reference Viewer сам по себе не источник истины: он | Уровней | 1072 | | OFPA-пакетов | 16 117 | | Полный свип | ~1.1 с | -| `WP_Main` | ~1.6 с, 8 644 ассета | +| `WP_Example` | ~1.6 с, 8 644 ассета | | Все уровни | 33 261 `UsedOnLevel` | | Отчёт | 45 167 строк, JSON 21.8 МБ + CSV 10.2 МБ | From 640e940ab4e7595045bcc3f7c679c281efd5e254 Mon Sep 17 00:00:00 2001 From: MagentaDolphin Date: Mon, 7 Sep 2026 18:39:39 +0700 Subject: [PATCH 5/8] docs: anonymise project asset names in source comments Measured figures are kept; only the studio-specific level and asset names are generalised, and the Perforce rationale is reworded to describe the class of setups rather than this one project. Co-Authored-By: Claude Code --- .../Private/AssetDependencyClosure.cpp | 4 +-- .../Private/AssetImporter.cpp | 2 +- .../Private/AssetUsageExporter.cpp | 2 +- .../Private/LevelUsageResolver.cpp | 4 +-- .../Public/AssetDependencyClosure.h | 4 +-- .../Public/AssetUsageAuditTypes.h | 6 ++--- .../Public/AssetUsagePaths.h | 2 +- .../Public/IndirectReferenceScanner.h | 4 +-- .../Public/LevelUsageResolver.h | 6 ++--- .../Private/AssetUsageAuditSettings.cpp | 4 +-- .../Private/SAssetDeleteDialog.cpp | 2 +- .../Private/SAssetUsageAuditPanel.cpp | 2 +- .../Public/AssetUsageAuditLibrary.h | 2 +- .../Public/AssetUsageAuditSettings.h | 2 +- .../Private/AssetDeletionScan.spec.cpp | 2 +- .../Private/AssetDependencyClosure.spec.cpp | 4 +-- .../Private/AssetUsageExporterLive.spec.cpp | 4 +-- .../Private/AssetUsagePaths.spec.cpp | 22 ++++++++-------- .../Private/AssetUsageReportWriter.spec.cpp | 14 +++++----- .../Private/AssetUsageVerdict.spec.cpp | 6 ++--- .../Private/IndirectReferenceScanner.spec.cpp | 26 +++++++++---------- 21 files changed, 62 insertions(+), 62 deletions(-) diff --git a/Source/AssetUsageAuditCore/Private/AssetDependencyClosure.cpp b/Source/AssetUsageAuditCore/Private/AssetDependencyClosure.cpp index c2ea1a3..7563f46 100644 --- a/Source/AssetUsageAuditCore/Private/AssetDependencyClosure.cpp +++ b/Source/AssetUsageAuditCore/Private/AssetDependencyClosure.cpp @@ -252,8 +252,8 @@ namespace AssetDependencyClosure // comes from a level or from one of its external actor packages - the shape a // streaming sublevel or a Level Instance actually has. A World named by an ordinary // asset is a different location that happens to be mentioned, and following it - // exports someone else's map: measured on WP_Main, the chain - // BP_FirstPersonGameMode -> PDA_MenuSystemConfig -> L_MainLevel accounts for 9994 + // exports someone else's map: measured on WP_Example, the chain + // BP_GameMode -> PDA_MenuConfig -> L_Other accounts for 9994 // packages. FLevelUsageResolver applies the same rule; the two must not disagree // about the same graph. if (IsLevelPackage(Dependency)) diff --git a/Source/AssetUsageAuditCore/Private/AssetImporter.cpp b/Source/AssetUsageAuditCore/Private/AssetImporter.cpp index 27288a5..5ba5d9e 100644 --- a/Source/AssetUsageAuditCore/Private/AssetImporter.cpp +++ b/Source/AssetUsageAuditCore/Private/AssetImporter.cpp @@ -146,7 +146,7 @@ namespace AssetImporter continue; } - // This project is Perforce-primary, so an existing target is very likely read-only. + // Perforce-primary projects keep unopened files read-only, 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)) diff --git a/Source/AssetUsageAuditCore/Private/AssetUsageExporter.cpp b/Source/AssetUsageAuditCore/Private/AssetUsageExporter.cpp index b10f871..a477963 100644 --- a/Source/AssetUsageAuditCore/Private/AssetUsageExporter.cpp +++ b/Source/AssetUsageAuditCore/Private/AssetUsageExporter.cpp @@ -536,7 +536,7 @@ FAssetUsageExporter::FResult FAssetUsageExporter::CopyExpandedPackages(const TAr const bool bWasPresent = FileManager.FileExists(*DestinationPath); - // This repository is Perforce-primary, so the source .uasset is very often read-only on + // Perforce-primary repositories keep unopened files read-only, so the source .uasset is very often read-only on // disk. Windows CopyFile carries that attribute across, which would make the export folder // read-only too - and then an Overwrite-policy re-export fails on its own previous output. // Clear the flag on both ends of the problem: before writing over an existing file, and diff --git a/Source/AssetUsageAuditCore/Private/LevelUsageResolver.cpp b/Source/AssetUsageAuditCore/Private/LevelUsageResolver.cpp index cf39d2f..1a4c14f 100644 --- a/Source/AssetUsageAuditCore/Private/LevelUsageResolver.cpp +++ b/Source/AssetUsageAuditCore/Private/LevelUsageResolver.cpp @@ -243,8 +243,8 @@ void FLevelUsageResolver::TraverseLevel(int32 LevelIndex, const FLevelUsageResol } // Reaching another World is NOT automatically a sublevel. An earlier version assumed it - // was, and on WP_Main that assumption pulled the whole of L_MainLevel in through - // BP_FirstPersonGameMode -> PDA_MenuSystemConfig: 9994 of 18136 rows belonged to a + // was, and on WP_Example that assumption pulled the whole of L_Other in through + // BP_GameMode -> PDA_MenuConfig: 9994 of 18136 rows belonged to a // different map. A crossing only counts when the reference comes from the level package // itself or from one of its external actor packages, which is the shape a streaming // sublevel or a Level Instance actually has. diff --git a/Source/AssetUsageAuditCore/Public/AssetDependencyClosure.h b/Source/AssetUsageAuditCore/Public/AssetDependencyClosure.h index b91830f..f25ad4c 100644 --- a/Source/AssetUsageAuditCore/Public/AssetDependencyClosure.h +++ b/Source/AssetUsageAuditCore/Public/AssetDependencyClosure.h @@ -73,8 +73,8 @@ namespace AssetDependencyClosure * Maps reached through an ordinary asset rather than through a level, and therefore not * followed - nor included. * - * The same rule FLevelUsageResolver applies, and for the same measured reason: on WP_Main - * the chain BP_FirstPersonGameMode -> PDA_MenuSystemConfig -> L_MainLevel drags in 9994 + * The same rule FLevelUsageResolver applies, and for the same measured reason: on WP_Example + * the chain BP_GameMode -> PDA_MenuConfig -> L_Other drags in 9994 * packages belonging to a different map. Without this the export dialog would quote an * honest number for a wrong set. * diff --git a/Source/AssetUsageAuditCore/Public/AssetUsageAuditTypes.h b/Source/AssetUsageAuditCore/Public/AssetUsageAuditTypes.h index 733b258..b296656 100644 --- a/Source/AssetUsageAuditCore/Public/AssetUsageAuditTypes.h +++ b/Source/AssetUsageAuditCore/Public/AssetUsageAuditTypes.h @@ -139,7 +139,7 @@ enum class EExportCollisionPolicy : uint8 */ struct ASSETUSAGEAUDITCORE_API FAssetUsageRow { - /** Package name, e.g. /Game/Space/Art/SM_Rock. */ + /** Package name, e.g. /Game/Art/SM_Rock. */ FName PackageName; /** Asset name without path. */ @@ -154,7 +154,7 @@ struct ASSETUSAGEAUDITCORE_API FAssetUsageRow */ FTopLevelAssetPath GeneratedClassPath; - /** Path relative to the project root, e.g. Content/Space/Art/SM_Rock.uasset. */ + /** Path relative to the project root, e.g. Content/Art/SM_Rock.uasset. */ FString PathFromProjectRoot; EAssetUsageVerdict Verdict = EAssetUsageVerdict::Unknown; @@ -172,7 +172,7 @@ struct ASSETUSAGEAUDITCORE_API FAssetUsageRow /** * Human-readable chain explaining the verdict, e.g. - * "WP_Main -> __ExternalActors__/.../A2B -> BP_Rock -> SM_Rock". + * "WP_Example -> __ExternalActors__/.../A2B -> BP_Rock -> SM_Rock". * A verdict without a route is unactionable for an artist. */ FString Route; diff --git a/Source/AssetUsageAuditCore/Public/AssetUsagePaths.h b/Source/AssetUsageAuditCore/Public/AssetUsagePaths.h index b47fc2c..d0353fc 100644 --- a/Source/AssetUsageAuditCore/Public/AssetUsagePaths.h +++ b/Source/AssetUsageAuditCore/Public/AssetUsagePaths.h @@ -51,7 +51,7 @@ namespace AssetUsagePaths /** * Package name to a path relative to the project root, as the report requires. - * "/Game/Space/Art/SM_Rock" -> "Content/Space/Art/SM_Rock.uasset" + * "/Game/Art/SM_Rock" -> "Content/Art/SM_Rock.uasset" * * @param bIsLevel Chooses the .umap extension over .uasset. * @return Empty for packages with no project-relative form, e.g. /Script or /Engine. diff --git a/Source/AssetUsageAuditCore/Public/IndirectReferenceScanner.h b/Source/AssetUsageAuditCore/Public/IndirectReferenceScanner.h index a7bf58a..d2a350c 100644 --- a/Source/AssetUsageAuditCore/Public/IndirectReferenceScanner.h +++ b/Source/AssetUsageAuditCore/Public/IndirectReferenceScanner.h @@ -9,7 +9,7 @@ */ struct FIndirectReference { - /** Package name the literal resolved to, e.g. /Game/Space/Core/GameModes/BP_FirstPersonGameMode. */ + /** Package name the literal resolved to, e.g. /Game/Core/GameModes/BP_GameMode. */ FName PackageName; /** Absolute path of the file the literal was found in. */ @@ -28,7 +28,7 @@ struct FIndirectReference * Finds asset paths referenced from text rather than from a package. * * This exists because of a concrete, measured failure mode on this project: Config/DefaultEngine.ini - * names BP_FirstPersonGameMode, BP_MenuSystemGameInstance, RefinedMenuMap and Gyms_Geoda as plain + * names BP_GameMode, BP_MenuGameInstance, RefinedMenuMap and Gyms_Geoda as plain * strings. No asset references them, so the Asset Registry reports zero referencers and a naive * audit calls the project's GameMode unused. * diff --git a/Source/AssetUsageAuditCore/Public/LevelUsageResolver.h b/Source/AssetUsageAuditCore/Public/LevelUsageResolver.h index 543def5..eca710f 100644 --- a/Source/AssetUsageAuditCore/Public/LevelUsageResolver.h +++ b/Source/AssetUsageAuditCore/Public/LevelUsageResolver.h @@ -37,8 +37,8 @@ struct FLevelUsageResolveOptions * * Off by default, and that default is load-bearing. A map does not only reference its own * sublevels: anything it can reach may name an unrelated map, and on this project it does. - * WP_Main -> BP_FirstPersonGameMode -> PDA_MenuSystemConfig -> L_MainLevel drags in the whole - * of L_MainLevel, which measured at 9994 of WP_Main's 18136 rows - two thirds of the answer + * WP_Example -> BP_GameMode -> PDA_MenuConfig -> L_Other drags in the whole + * of L_Other, which measured at 9994 of WP_Example's 18136 rows - two thirds of the answer * was another level's content. * * With this off, a foreign map is still reported as referenced, but its contents are attributed @@ -72,7 +72,7 @@ struct FLevelUsageStats * Foreign maps referenced but deliberately not expanded into. * * A non-zero value here is the amount of another level's content that would otherwise have - * been attributed to this one. Worth surfacing: on WP_Main it was two thirds of the report. + * been attributed to this one. Worth surfacing: on WP_Example it was two thirds of the report. */ int32 ForeignLevelsNotExpanded = 0; double ScanLevelAssetsSeconds = 0.0; diff --git a/Source/AssetUsageAuditEditor/Private/AssetUsageAuditSettings.cpp b/Source/AssetUsageAuditEditor/Private/AssetUsageAuditSettings.cpp index bb918c3..beee221 100644 --- a/Source/AssetUsageAuditEditor/Private/AssetUsageAuditSettings.cpp +++ b/Source/AssetUsageAuditEditor/Private/AssetUsageAuditSettings.cpp @@ -11,8 +11,8 @@ UAssetUsageAuditSettings::UAssetUsageAuditSettings() // third-party, so leaving these in makes the first run unreadable for an artist looking for // their own content. // - // Deliberately NOT excluded, despite being a bought pack: Content/MenuSystemPro. It supplies - // BP_MenuSystemGameInstance, which DefaultEngine.ini names as the project's GameInstance. + // Deliberately NOT excluded, despite being a bought pack: Content/a purchased UI pack. It supplies + // BP_MenuGameInstance, which DefaultEngine.ini names as the project's GameInstance. // Excluding it would hide a load-bearing asset from every report. ExcludedPackagePaths = { TEXT("Content/3rdParty"), diff --git a/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.cpp b/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.cpp index 54b6253..569e573 100644 --- a/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.cpp +++ b/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.cpp @@ -178,7 +178,7 @@ public: return SNew(STextBlock) .Text(LOCTEXT("ReadOnly", "read-only on disk")) .ColorAndOpacity(FSlateColor(CautionColor)) - .ToolTipText(LOCTEXT("ReadOnlyTip", "The file is not writable. This project is Perforce-primary, so an unopened file is read-only and the delete will fail on it. Check it out first.")); + .ToolTipText(LOCTEXT("ReadOnlyTip", "The file is not writable. Perforce-primary projects keep unopened files read-only, so an unopened file is read-only and the delete will fail on it. Check it out first.")); } return SNew(STextBlock) diff --git a/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.cpp b/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.cpp index e2d78e2..23ce8e4 100644 --- a/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.cpp +++ b/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.cpp @@ -375,7 +375,7 @@ void SAssetUsageAuditPanel::Construct(const FArguments& InArgs) } // Name the level when there is one, count them when there are several: - // a button reading "WP_Main" is worth more than one reading "1 level". + // a button reading "WP_Example" is worth more than one reading "1 level". if (LevelFilters.Num() == 1) { return FText::FromString(FPackageName::GetShortName(*LevelFilters.CreateConstIterator())); diff --git a/Source/AssetUsageAuditEditor/Public/AssetUsageAuditLibrary.h b/Source/AssetUsageAuditEditor/Public/AssetUsageAuditLibrary.h index a2b31ba..8c250c7 100644 --- a/Source/AssetUsageAuditEditor/Public/AssetUsageAuditLibrary.h +++ b/Source/AssetUsageAuditEditor/Public/AssetUsageAuditLibrary.h @@ -25,7 +25,7 @@ public: /** * Run a full audit and write JSON + CSV reports. * - * @param LevelPackageNames Levels to analyse, e.g. "/Game/Space/Maps/WP_Main". Empty means all. + * @param LevelPackageNames Levels to analyse, e.g. "/Game/Maps/WP_Example". Empty means all. * @param OutputDirectory Destination. Empty uses the configured default. * @param OutReportPath Absolute path of the JSON report on success. * @return true when both reports were written. diff --git a/Source/AssetUsageAuditEditor/Public/AssetUsageAuditSettings.h b/Source/AssetUsageAuditEditor/Public/AssetUsageAuditSettings.h index 495ea88..33e347c 100644 --- a/Source/AssetUsageAuditEditor/Public/AssetUsageAuditSettings.h +++ b/Source/AssetUsageAuditEditor/Public/AssetUsageAuditSettings.h @@ -10,7 +10,7 @@ /** * Team-shared configuration, written to Config/DefaultEditor.ini. * - * That file is Perforce-tracked (only Plugins/** is p4-ignored), so the exclusion list and the + * That file is normally under version control, so the exclusion list and the * type presets are reviewable and shared. Per-user state - last filter, column layout - belongs * in UAssetUsageAuditUserSettings instead, which stays in Saved/Config. */ diff --git a/Source/AssetUsageAuditTests/Private/AssetDeletionScan.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetDeletionScan.spec.cpp index 2e862af..40b6e7a 100644 --- a/Source/AssetUsageAuditTests/Private/AssetDeletionScan.spec.cpp +++ b/Source/AssetUsageAuditTests/Private/AssetDeletionScan.spec.cpp @@ -399,7 +399,7 @@ void AssetDeletionScanSpec::Define() return; } - // This project is Perforce-primary, so read-only is the normal state of an unopened + // Perforce-primary projects keep unopened files read-only, so read-only is the normal state of an unopened // file. Reporting it up front is the difference between a warning and a delete that // fails halfway through the selection. TestEqual(TEXT("matches the filesystem"), diff --git a/Source/AssetUsageAuditTests/Private/AssetDependencyClosure.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetDependencyClosure.spec.cpp index b88158c..ce1255a 100644 --- a/Source/AssetUsageAuditTests/Private/AssetDependencyClosure.spec.cpp +++ b/Source/AssetUsageAuditTests/Private/AssetDependencyClosure.spec.cpp @@ -232,8 +232,8 @@ void AssetDependencyClosureSpec::Define() It("does not follow a map that an ordinary asset merely mentions", [this]() { - // The defect this guards against: exporting WP_Main pulled in the whole of L_MainLevel - // through BP_FirstPersonGameMode -> PDA_MenuSystemConfig, 9994 packages belonging to a + // The defect this guards against: exporting WP_Example pulled in the whole of L_Other + // through BP_GameMode -> PDA_MenuConfig, 9994 packages belonging to a // different location. FLevelUsageResolver already refused that crossing; the closure // did not, so the two disagreed about the same graph. IAssetRegistry& Registry = ReadyRegistry(); diff --git a/Source/AssetUsageAuditTests/Private/AssetUsageExporterLive.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetUsageExporterLive.spec.cpp index 550d540..9430a84 100644 --- a/Source/AssetUsageAuditTests/Private/AssetUsageExporterLive.spec.cpp +++ b/Source/AssetUsageAuditTests/Private/AssetUsageExporterLive.spec.cpp @@ -176,7 +176,7 @@ BEGIN_DEFINE_SPEC(AssetUsageExporterLiveSpec, /** * The OFPA level with the fewest external actors. * - * Smallest on purpose: WP_Main owns roughly 16000 external packages and copying those in a test + * Smallest on purpose: WP_Example owns roughly 16000 external packages and copying those in a test * would move gigabytes. Search stops as soon as something small enough turns up. */ static FName FindSmallestOfpaLevel(int32& OutExternalCount) @@ -254,7 +254,7 @@ void AssetUsageExporterLiveSpec::Define() It("leaves the exported file writable even when the source is read-only", [this]() { - // This project is Perforce-primary, so unopened files are read-only on disk and Windows + // Perforce-primary projects keep unopened files read-only, so unopened files are read-only on disk and Windows // CopyFile carries the attribute across. A read-only export folder is useless to an // artist and breaks the exporter's own Overwrite policy on the next run. const FName Package = FindOrdinaryAssetPackage(); diff --git a/Source/AssetUsageAuditTests/Private/AssetUsagePaths.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetUsagePaths.spec.cpp index f265246..2d893d8 100644 --- a/Source/AssetUsageAuditTests/Private/AssetUsagePaths.spec.cpp +++ b/Source/AssetUsageAuditTests/Private/AssetUsagePaths.spec.cpp @@ -16,9 +16,9 @@ void AssetUsagePathsSpec::Define() { It("detects an OFPA external actor package", [this]() { - // Real shape from this project: /Game/__ExternalActors__/Space/Maps/WP_Main/8/AB/<22 chars> + // Real shape from this project: /Game/__ExternalActors__/Space/Maps/WP_Example/8/AB/<22 chars> TestTrue(TEXT("external actor"), - AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/__ExternalActors__/Space/Maps/WP_Main/8/AB/QWERTYUIOPASDFGHJKLZXC")))); + AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/__ExternalActors__/Space/Maps/WP_Example/8/AB/QWERTYUIOPASDFGHJKLZXC")))); }); It("detects an external objects package", [this]() @@ -30,13 +30,13 @@ void AssetUsagePathsSpec::Define() It("does not flag an ordinary content package", [this]() { TestFalse(TEXT("ordinary asset"), - AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/Space/Art/SM_Rock")))); + AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/Art/SM_Rock")))); }); It("does not flag a level that merely lives next to external actors", [this]() { TestFalse(TEXT("the level itself is not external"), - AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/Space/Maps/WP_Main")))); + AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/Maps/WP_Example")))); }); }); @@ -123,7 +123,7 @@ void AssetUsagePathsSpec::Define() { const TArray Excluded; TestFalse(TEXT("nothing excluded"), - AssetUsagePaths::IsPathExcluded(FName(TEXT("/Game/Space/Art/SM_Rock")), Excluded)); + AssetUsagePaths::IsPathExcluded(FName(TEXT("/Game/Art/SM_Rock")), Excluded)); }); }); @@ -132,15 +132,15 @@ void AssetUsagePathsSpec::Define() It("converts an asset package", [this]() { TestEqual(TEXT("asset"), - AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/Space/Art/SM_Rock")), false), - FString(TEXT("Content/Space/Art/SM_Rock.uasset"))); + AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/Art/SM_Rock")), false), + FString(TEXT("Content/Art/SM_Rock.uasset"))); }); It("uses the umap extension for levels", [this]() { TestEqual(TEXT("level"), - AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/Space/Maps/WP_Main")), true), - FString(TEXT("Content/Space/Maps/WP_Main.umap"))); + AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/Maps/WP_Example")), true), + FString(TEXT("Content/Maps/WP_Example.umap"))); }); It("returns empty for script and engine packages rather than inventing a path", [this]() @@ -154,8 +154,8 @@ void AssetUsagePathsSpec::Define() It("converts an external actor package, which is still under /Game", [this]() { TestEqual(TEXT("external actor"), - AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/__ExternalActors__/Space/Maps/WP_Main/8/AB/ABC")), false), - FString(TEXT("Content/__ExternalActors__/Space/Maps/WP_Main/8/AB/ABC.uasset"))); + AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/__ExternalActors__/Space/Maps/WP_Example/8/AB/ABC")), false), + FString(TEXT("Content/__ExternalActors__/Space/Maps/WP_Example/8/AB/ABC.uasset"))); }); }); } diff --git a/Source/AssetUsageAuditTests/Private/AssetUsageReportWriter.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetUsageReportWriter.spec.cpp index bb15f1c..55f64ac 100644 --- a/Source/AssetUsageAuditTests/Private/AssetUsageReportWriter.spec.cpp +++ b/Source/AssetUsageAuditTests/Private/AssetUsageReportWriter.spec.cpp @@ -25,22 +25,22 @@ namespace Result.Header.AppliedFilters.Add(TEXT("Types: StaticMesh")); FAssetUsageRow& Used = Result.Rows.AddDefaulted_GetRef(); - Used.PackageName = FName(TEXT("/Game/Space/Art/SM_Rock")); + Used.PackageName = FName(TEXT("/Game/Art/SM_Rock")); Used.AssetName = FName(TEXT("SM_Rock")); Used.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine.StaticMesh")); - Used.PathFromProjectRoot = TEXT("Content/Space/Art/SM_Rock.uasset"); + Used.PathFromProjectRoot = TEXT("Content/Art/SM_Rock.uasset"); Used.Verdict = EAssetUsageVerdict::UsedOnLevel; - Used.Levels = { FName(TEXT("/Game/Space/Maps/WP_Main")), FName(TEXT("/Game/Space/Maps/L_3i_Hub")) }; + Used.Levels = { FName(TEXT("/Game/Maps/WP_Example")), FName(TEXT("/Game/Maps/L_3i_Hub")) }; Used.HardReferenceCount = 3; Used.SoftReferenceCount = 1; Used.Provenance = EAssetUsageProvenance::HardReference | EAssetUsageProvenance::ExternalActor; - Used.Route = TEXT("WP_Main -> BP_Rock -> SM_Rock"); + Used.Route = TEXT("WP_Example -> BP_Rock -> SM_Rock"); FAssetUsageRow& Unused = Result.Rows.AddDefaulted_GetRef(); - Unused.PackageName = FName(TEXT("/Game/Space/Art/SM_Orphan")); + Unused.PackageName = FName(TEXT("/Game/Art/SM_Orphan")); Unused.AssetName = FName(TEXT("SM_Orphan")); Unused.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine.StaticMesh")); - Unused.PathFromProjectRoot = TEXT("Content/Space/Art/SM_Orphan.uasset"); + Unused.PathFromProjectRoot = TEXT("Content/Art/SM_Orphan.uasset"); Unused.Verdict = EAssetUsageVerdict::Unreferenced; return Result; @@ -123,7 +123,7 @@ void AssetUsageReportWriterSpec::Define() const FString Csv = FAssetUsageReportWriter::BuildCsv(Sample, Options); TestTrue(TEXT("levels joined"), - Csv.Contains(TEXT("/Game/Space/Maps/WP_Main|/Game/Space/Maps/L_3i_Hub"))); + Csv.Contains(TEXT("/Game/Maps/WP_Example|/Game/Maps/L_3i_Hub"))); }); It("carries the verdict verbatim", [this]() diff --git a/Source/AssetUsageAuditTests/Private/AssetUsageVerdict.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetUsageVerdict.spec.cpp index d337c2c..b03d917 100644 --- a/Source/AssetUsageAuditTests/Private/AssetUsageVerdict.spec.cpp +++ b/Source/AssetUsageAuditTests/Private/AssetUsageVerdict.spec.cpp @@ -45,7 +45,7 @@ void AssetUsageVerdictSpec::Define() It("reports ReferencedFromConfigOrSource when only a text literal names it", [this]() { - // This is the BP_FirstPersonGameMode case: no asset references it, only + // This is the BP_GameMode case: no asset references it, only // Config/DefaultEngine.ini does. Reporting it Unreferenced would be a real defect. const EAssetUsageVerdict Verdict = FAssetUsageAuditor::ClassifyVerdict(false, false, true, false); @@ -126,7 +126,7 @@ void AssetUsageVerdictSpec::Define() { // A level depends on the whole table, so every row looks used even when none is. FAssetUsageNode Node; - Node.PackageName = FName(TEXT("/Game/Space/Data/DT_Weapons")); + Node.PackageName = FName(TEXT("/Game/Data/DT_Weapons")); Node.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine.DataTable")); TestTrue(TEXT("DataTable is a blind spot"), FAssetUsageAuditor::IsRegistryBlindSpot(Node)); @@ -135,7 +135,7 @@ void AssetUsageVerdictSpec::Define() It("does not flag an ordinary static mesh", [this]() { FAssetUsageNode Node; - Node.PackageName = FName(TEXT("/Game/Space/Art/SM_Rock")); + Node.PackageName = FName(TEXT("/Game/Art/SM_Rock")); Node.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine.StaticMesh")); TestFalse(TEXT("StaticMesh is not a blind spot"), FAssetUsageAuditor::IsRegistryBlindSpot(Node)); diff --git a/Source/AssetUsageAuditTests/Private/IndirectReferenceScanner.spec.cpp b/Source/AssetUsageAuditTests/Private/IndirectReferenceScanner.spec.cpp index 30d785c..5b01bdd 100644 --- a/Source/AssetUsageAuditTests/Private/IndirectReferenceScanner.spec.cpp +++ b/Source/AssetUsageAuditTests/Private/IndirectReferenceScanner.spec.cpp @@ -28,70 +28,70 @@ void IndirectReferenceScannerSpec::Define() It("finds the GameMode from DefaultEngine.ini and strips the _C class suffix", [this]() { const TArray Found = Extract( - TEXT("GlobalDefaultGameMode=/Game/Space/Core/GameModes/BP_FirstPersonGameMode.BP_FirstPersonGameMode_C")); + TEXT("GlobalDefaultGameMode=/Game/Core/GameModes/BP_GameMode.BP_GameMode_C")); TestEqual(TEXT("one hit"), Found.Num(), 1); if (Found.Num() == 1) { - TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Core/GameModes/BP_FirstPersonGameMode"))); + TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Core/GameModes/BP_GameMode"))); } }); - It("finds the GameInstance from MenuSystemPro", [this]() + It("finds the GameInstance from a purchased UI pack", [this]() { const TArray Found = Extract( - TEXT("GameInstanceClass=/Game/MenuSystemPro/Blueprints/GameFramework/BP_MenuSystemGameInstance.BP_MenuSystemGameInstance_C")); + TEXT("GameInstanceClass=/Game/a purchased UI pack/Blueprints/GameFramework/BP_MenuGameInstance.BP_MenuGameInstance_C")); TestEqual(TEXT("one hit"), Found.Num(), 1); if (Found.Num() == 1) { TestEqual(TEXT("package"), Found[0], - FString(TEXT("/Game/MenuSystemPro/Blueprints/GameFramework/BP_MenuSystemGameInstance"))); + FString(TEXT("/Game/a purchased UI pack/Blueprints/GameFramework/BP_MenuGameInstance"))); } }); It("finds a map reference with an object suffix", [this]() { - const TArray Found = Extract(TEXT("EditorStartupMap=/Game/Space/Maps/Gyms/Gyms_Geoda.Gyms_Geoda")); + const TArray Found = Extract(TEXT("EditorStartupMap=/Game/Maps/Gyms/Gyms_Geoda.Gyms_Geoda")); TestEqual(TEXT("one hit"), Found.Num(), 1); if (Found.Num() == 1) { - TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Maps/Gyms/Gyms_Geoda"))); + TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Maps/Gyms/Gyms_Geoda"))); } }); It("finds a bare package path with no object suffix", [this]() { - const TArray Found = Extract(TEXT("GameDefaultMap=/Game/Space/Maps/RefinedMenuMap")); + const TArray Found = Extract(TEXT("GameDefaultMap=/Game/Maps/RefinedMenuMap")); TestEqual(TEXT("one hit"), Found.Num(), 1); if (Found.Num() == 1) { - TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Maps/RefinedMenuMap"))); + TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Maps/RefinedMenuMap"))); } }); It("strips surrounding quotes and parentheses from an ini struct value", [this]() { - const TArray Found = Extract(TEXT("+DirectoriesToAlwaysCook=(Path=\"/Game/Space/Art\")")); + const TArray Found = Extract(TEXT("+DirectoriesToAlwaysCook=(Path=\"/Game/Art\")")); TestEqual(TEXT("one hit"), Found.Num(), 1); if (Found.Num() == 1) { - TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Art"))); + TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Art"))); } }); It("handles a C++ literal wrapped in TEXT()", [this]() { const TArray Found = Extract( - TEXT("static const FSoftObjectPath Path(TEXT(\"/Game/Space/Art/SM_Rock\"));")); + TEXT("static const FSoftObjectPath Path(TEXT(\"/Game/Art/SM_Rock\"));")); TestEqual(TEXT("one hit"), Found.Num(), 1); if (Found.Num() == 1) { - TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Art/SM_Rock"))); + TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Art/SM_Rock"))); } }); From a0a061656e3b6bc42dfb867b2e1ad541239a8f60 Mon Sep 17 00:00:00 2001 From: MagentaDolphin Date: Mon, 7 Sep 2026 18:41:51 +0700 Subject: [PATCH 6/8] docs: anonymise remaining project names in test fixtures Co-Authored-By: Claude Code --- .../Private/AssetImportScanner.cpp | 2 +- .../Private/AssetUsagePaths.spec.cpp | 14 +++++++------- .../Private/AssetUsageReportWriter.spec.cpp | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Source/AssetUsageAuditCore/Private/AssetImportScanner.cpp b/Source/AssetUsageAuditCore/Private/AssetImportScanner.cpp index 8866b80..c62602e 100644 --- a/Source/AssetUsageAuditCore/Private/AssetImportScanner.cpp +++ b/Source/AssetUsageAuditCore/Private/AssetImportScanner.cpp @@ -170,7 +170,7 @@ namespace AssetImportScanner /** * 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 + * Correct for a MirrorTree export, which writes Game/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) diff --git a/Source/AssetUsageAuditTests/Private/AssetUsagePaths.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetUsagePaths.spec.cpp index 2d893d8..287d8d0 100644 --- a/Source/AssetUsageAuditTests/Private/AssetUsagePaths.spec.cpp +++ b/Source/AssetUsageAuditTests/Private/AssetUsagePaths.spec.cpp @@ -16,15 +16,15 @@ void AssetUsagePathsSpec::Define() { It("detects an OFPA external actor package", [this]() { - // Real shape from this project: /Game/__ExternalActors__/Space/Maps/WP_Example/8/AB/<22 chars> + // Real shape from a live project: /Game/__ExternalActors__/Maps/WP_Example/8/AB/<22 chars> TestTrue(TEXT("external actor"), - AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/__ExternalActors__/Space/Maps/WP_Example/8/AB/QWERTYUIOPASDFGHJKLZXC")))); + AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/__ExternalActors__/Maps/WP_Example/8/AB/QWERTYUIOPASDFGHJKLZXC")))); }); It("detects an external objects package", [this]() { TestTrue(TEXT("external object"), - AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/__ExternalObjects__/Space/Maps/L_3i_Hub/1/2A/ABC")))); + AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/__ExternalObjects__/Maps/L_3i_Hub/1/2A/ABC")))); }); It("does not flag an ordinary content package", [this]() @@ -44,7 +44,7 @@ void AssetUsagePathsSpec::Define() { It("flags /Script packages", [this]() { - TestTrue(TEXT("script"), AssetUsagePaths::IsScriptPackage(FName(TEXT("/Script/Space")))); + TestTrue(TEXT("script"), AssetUsagePaths::IsScriptPackage(FName(TEXT("/Script/MyGame")))); }); It("does not flag content that starts with the same letters", [this]() @@ -146,7 +146,7 @@ void AssetUsagePathsSpec::Define() It("returns empty for script and engine packages rather than inventing a path", [this]() { TestTrue(TEXT("script"), - AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Script/Space")), false).IsEmpty()); + AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Script/MyGame")), false).IsEmpty()); TestTrue(TEXT("engine"), AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Engine/BasicShapes/Cube")), false).IsEmpty()); }); @@ -154,8 +154,8 @@ void AssetUsagePathsSpec::Define() It("converts an external actor package, which is still under /Game", [this]() { TestEqual(TEXT("external actor"), - AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/__ExternalActors__/Space/Maps/WP_Example/8/AB/ABC")), false), - FString(TEXT("Content/__ExternalActors__/Space/Maps/WP_Example/8/AB/ABC.uasset"))); + AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/__ExternalActors__/Maps/WP_Example/8/AB/ABC")), false), + FString(TEXT("Content/__ExternalActors__/Maps/WP_Example/8/AB/ABC.uasset"))); }); }); } diff --git a/Source/AssetUsageAuditTests/Private/AssetUsageReportWriter.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetUsageReportWriter.spec.cpp index 55f64ac..5b144ed 100644 --- a/Source/AssetUsageAuditTests/Private/AssetUsageReportWriter.spec.cpp +++ b/Source/AssetUsageAuditTests/Private/AssetUsageReportWriter.spec.cpp @@ -17,7 +17,7 @@ namespace FAssetUsageAuditResult Result; Result.Header.GeneratedAt = FDateTime(2026, 9, 2, 12, 0, 0); - Result.Header.ProjectName = TEXT("Space"); + Result.Header.ProjectName = TEXT("MyProject"); Result.Header.EngineVersion = TEXT("5.6.1"); Result.Header.ToolVersion = TEXT("0.1"); Result.Header.LevelsScanned = 2; @@ -172,7 +172,7 @@ void AssetUsageReportWriterSpec::Define() TestTrue(TEXT("header object"), Root->TryGetObjectField(TEXT("header"), Header)); if (Header) { - TestEqual(TEXT("project"), (*Header)->GetStringField(TEXT("project")), FString(TEXT("Space"))); + TestEqual(TEXT("project"), (*Header)->GetStringField(TEXT("project")), FString(TEXT("MyProject"))); TestEqual(TEXT("levelsScanned"), (*Header)->GetIntegerField(TEXT("levelsScanned")), 2); } From 63c4054aefdd165c9c92766d22c686bcc34e1cee Mon Sep 17 00:00:00 2001 From: MagentaDolphin Date: Mon, 7 Sep 2026 18:47:20 +0700 Subject: [PATCH 7/8] docs: correct test counts (169 cases in 16 spec suites) Co-Authored-By: Claude Code --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d4a9660..af91180 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ BOM обязателен: без него Excel ломает кириллицу. ``` AssetUsageAuditCore UncookedOnly — весь анализ, ноль UI AssetUsageAuditEditor Editor — Slate-панель, настройки, команды -AssetUsageAuditTests UncookedOnly — 128 спек +AssetUsageAuditTests UncookedOnly — 16 spec-сьютов, 169 кейсов ``` **Инвариант Core:** не линковать `UnrealEd`, `AssetTools`, `ToolMenus`, `Slate`, `SlateCore`, `EditorSubsystem`. `AssetTools` editor-only транзитивно через `UnrealEd`. @@ -386,7 +386,7 @@ WP_Example → BP_GameMode → PDA_MenuConfig → L_Other → … -unattended -nopause -nosplash -stdout -abslog="" ``` -**128 спек**, префикс `AssetUsageAudit.*`. +**169 кейсов в 16 spec-сьютах**, префикс `AssetUsageAudit.*`. Три набора работают на **настоящем контенте проекта**, а не на выдуманных именах: `ExporterLive` (копирование, коллизии, раскрытие OFPA), `ExchangeExport` (конвертация) и `GraphFidelity` (сверка графа с реестром). Субъект они ищут через Asset Registry и берут **самый маленький** OFPA-уровень — на `WP_Example` тест копировал бы гигабайты. Если контент не найден, тест пишет предупреждение и не падает, поэтому в логе стоит смотреть на предупреждения: их отсутствие означает, что тесты реально работали с контентом. @@ -435,7 +435,7 @@ Reference Viewer сам по себе не источник истины: он ## Незакрытое -- **UI после переделки в дерево руками не проверялся.** Компилируется, 121 автотест зелёный, но автотесты не трогают Slate: раскрытие уровней, третье состояние галочки на заголовке и сортировка внутри группы проверены только чтением кода. +- **UI после переделки в дерево руками не проверялся.** Компилируется, но автотесты не трогают Slate: раскрытие уровней, третье состояние галочки на заголовке и сортировка внутри группы проверены только чтением кода. - **Коммандлет не написан.** Упирается в дефект headless выше. - **Конвертация не прогонялась на большом объёме.** Тесты покрывают единицы ассетов; поведение GC и времени на тысячах — не замерено. - **Карта форматов узкая.** В дефолте только классы, для которых движок реально поставляет `UExporter`. Материалы, блюпринты и Niagara не конвертируются никуда — это ограничение движка, а не недоделка, но пользователя нужно об этом предупредить явно. From d4161cdc773564bbb7040480f973d34697b584cb Mon Sep 17 00:00:00 2001 From: MagentaDolphin Date: Mon, 7 Sep 2026 18:57:14 +0700 Subject: [PATCH 8/8] fix: asset paths cannot contain spaces in test fixture The anonymisation pass replaced a pack name with a phrase containing spaces, and the scanner splits package paths on whitespace, so the expectation became /Game/a. Uses a single-token folder name instead. Co-Authored-By: Claude Code --- .../Private/IndirectReferenceScanner.spec.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Source/AssetUsageAuditTests/Private/IndirectReferenceScanner.spec.cpp b/Source/AssetUsageAuditTests/Private/IndirectReferenceScanner.spec.cpp index 5b01bdd..4c0c5d0 100644 --- a/Source/AssetUsageAuditTests/Private/IndirectReferenceScanner.spec.cpp +++ b/Source/AssetUsageAuditTests/Private/IndirectReferenceScanner.spec.cpp @@ -37,16 +37,16 @@ void IndirectReferenceScannerSpec::Define() } }); - It("finds the GameInstance from a purchased UI pack", [this]() + It("finds the GameInstance from a purchased pack", [this]() { const TArray Found = Extract( - TEXT("GameInstanceClass=/Game/a purchased UI pack/Blueprints/GameFramework/BP_MenuGameInstance.BP_MenuGameInstance_C")); + TEXT("GameInstanceClass=/Game/UIPack/Blueprints/GameFramework/BP_MenuGameInstance.BP_MenuGameInstance_C")); TestEqual(TEXT("one hit"), Found.Num(), 1); if (Found.Num() == 1) { TestEqual(TEXT("package"), Found[0], - FString(TEXT("/Game/a purchased UI pack/Blueprints/GameFramework/BP_MenuGameInstance"))); + FString(TEXT("/Game/UIPack/Blueprints/GameFramework/BP_MenuGameInstance"))); } });