Vladimir Chavdarov
Tools and Gameplay Programmer
Vladimir Chavdarov
Tools and Gameplay Programmer
In order to develop "Project: Pest Control", a 21-person team had to work in cohesion together on one project. In UE5, one of the most common ways of looking for a specific file is simply using the search bar in the Content Browser. However, when you have dozens or hundreds of, let's say, Blueprints, you can't possibly remember the name of each in order to search effectively. That's why we use prefixes. In fact, there are so common, that Epic Games provides official naming conventions.
When working in a team, it's important to follow a uniform standard so everyone is aware of how things should be written or where can they be found. The simplest form of a standard is the naming convention - and in the context of UE5, also prefixes. In order to streamline the tiresome task of always checking if your new asset has the right name, I automated this process so the developer can focus on the creative work.
The goal of the Validation Tool is to automatically check files, both on individual saves or in bulk, and determine whether they should be renamed or not. However, sometimes, an new asset is used and there is no naming convention established for it. The tool also handles this case by prompting the user to update the in-house dictionary on the spot.
Unreal Engine 5 has its own built-in validator - this is the system that prompts you with the Message Log filled with errors when you do something wrong! Fortunately, they also provide an option for the user to extend it with their own custom rules. We start by creating an Editor Utility Blueprint, inheriting from Editor Validator Base. This blueprint has three functions we can override:
CanValidate - Whether this validator should be ran in the first place given the use case.
CanValidateAsset - Is it appropriate to validate the current asset with this specific validator? (e.g. you won't review the poly count of a .cpp file)
ValidateLoadedAsset - Determine the rules under which an asset is considered valid or not.
In our case, we don't need to override CanValidate since we want to execute this validator as often as possible.
In the beginning, I simply ignored this function and accepted all assets in the respective folder that I wanted to validate. However, after testing, I got a bunch of assets that failed the validation. The curious part was that those were assets of type ObjectRedirector and they literally didn't exist in the Content Browser (or so I thought). This type of asset is created when files are being deleted or removed, kind of like their footprint. They can usually be resolved with the "Update Redirector Referenced" command when you right-click on a folder. However, as stated earlier, the goal for this tool is to streamline the process of the developer and dealing with object redirectors while you're trying to figure out a core mechanic in the game isn't really pleasant for anyone (that is why they are invisible by default). So I will make the validator ignore them. This is easily done in blueprints like so:
Before we validate our assets, we need to know what we're validating against. At first, I decided to use a member map since each type of file should have its own prefix. When I introduced the tool in the project though, I noticed this is not always the case. For example, in our standard, we use "L_" for levels but we use "GL_" for gym levels specifically. This distinction is only known to humans, for the engine, the class type in both cases is just "World". That's why I decided to switch to a data table. It is a more flexible alternative and it also promotes good separation of concerns. On the left, you can see a section of our dictionary, including the Level example.
When validating, we simply iterate over the data table, looking to match the Class Name of the asset with a Class Name column entry from the dictionary.
After that, we check whether the name of the file starts with the prefix we obtained from the dictionary. Depending on the result of that check, we use the "Asset Passes" and "Asset Fails" functions included in the Editor Validator Base class.
The result from this script is that the error will appear in the Message Log every time an asset doesn't follow the established naming convention. I did it so it directly suggests the correct prefix and the only thing the developer needs to do is rename their file. On the left is a screenshot of a file of type BlendSpace with a very wrong name. And below is the error the user receives after trying to validate it:
"WRONG PREFIX: [tyhgrfed] of type [BlendSpace] does not have the correct prefix. Add [BS_] to the name.."
Blueprints are bit more complex, compared to other assets like Static Meshes or Materials for example. Let's say I create a a Pawn called "BP_MyPawn". If I call the GetClass->GetClassDisplayName nodes on BP_MyPawn, I will get a string that says "Blueprint". This is not very useful since usually blueprints have various prefixes due to their versatility - "AC_" for ActorComponent, "BPI_" for Interfaces, "AIC_" for AIController, etc.
Interestingly enough, if we hover over a blueprint class, we can see that "Parent Class", it shows the exact data we're looking for. However, you can't obtain the parent class of a blueprint with scripting nodes. So we need a small C++ function that is set to BlueprintCallable:
UClass* UValidationBPLibrary::GetBPParent(UBlueprint* Blueprint)
{
return Blueprint ? Blueprint->ParentClass : nullptr;
}
That way, we can also obtain the immediate parent class with Editor nodes. I wrapped this in another function to keep things short and organized:
The full logic for handling assets of type "Blueprint" looks like this:
When the validator is checking a file, sometimes it won't find an entry in the dictionary. This means the new class must be registered. In my initial prototype, I showed an error message, telling the developer to manually go to the data table and add a new row. However, I saw that most of the time, my teammates were just ignoring the error and were continuing with their work as normal. After receiving feedback, I realized that requiring from the developer to manually update a data table was a tiresome process and was actually getting in the way rather than helping. So I decided to take away as many steps from this process as possible.
The solution I came up with was to prompt the user to enter the a prefix for each unregistered asset that goes through the validation process. That way, the steps needed to complete the same process are reduced by half:
Manual Update:
Read the Message
Locate the data table
Open it
Click the Add button
Type Row name and Class name
Decide on a good prefix
Type new prefix in the new row
Get back to your work
Prompt Update:
Read the Prompt
Decide on a good prefix
Type new prefix in the input field
Get back to your work
The most important part about displaying the prompt is that it should pause the validation process until the window is gone. I did that with a custom dialog window. Fortunately, Unreal provides the classes to create your own dialog windows, so I created a C++ class inheriting from UEditorDialogLibrary.
UCLASS()
class VALIDATIONMODULE_API UValidationDialogNewPrefix : public UEditorDialogLibrary
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, DisplayName = "Show New Prefix Dialog", Category = "Editor Scripting | Message Dialog")
static FString ShowNewPrefixPrompt(const FText& Title, const FString& RowName, const FString& ClassName, const FEditorDialogLibraryObjectDetailsViewOptions& Options);
};
In the .cpp file we need to create a custom Slate class so we can order the widgets in our Dialog however we want. In this case, we simply want two text boxes (for file name and class name) and an input text field with a label (for the prefix). This class was directly inspired by the SObjParamDialog class found in the UEditorDialogLibrary.cpp file. Below is a small section of the full Slate class:
class SPrefixInputDialog : public SCompoundWidget
{
public:
SLATE_BEGIN_ARGS(SPrefixInputDialog) {}
SLATE_ARGUMENT(FText, DialogTitle)
SLATE_ARGUMENT(FText, FileName)
SLATE_ARGUMENT(FText, ClassName)
SLATE_ARGUMENT(FText, InputLabel)
SLATE_END_ARGS()
void Construct(const FArguments& InArgs, TWeakPtr<SWindow> InParentWindow, const FEditorDialogLibraryObjectDetailsViewOptions& Options = FEditorDialogLibraryObjectDetailsViewOptions())
{
bOKPressed = false;
ParentWindow = InParentWindow;
ChildSlot
[
SNew(SVerticalBox)
// First Input Field
// Row Name
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(5)
[
SNew(STextBlock)
.Text(InArgs._FileName)
]
// Class Name, Input Box Label, Input Box, Buttons
// [...]
];
}
bool WasOkPressed() const { return bOKPressed; }
FString GetPrefixInput() const { return PrefixInput; }
private:
void CloseWindow()
{
if (ParentWindow.IsValid())
ParentWindow.Pin()->RequestDestroyWindow();
}
private:
TWeakPtr<SWindow> ParentWindow;
bool bOKPressed;
FString FileName;
FString ClassName;
FString PrefixInput;
};
After creating the Slate Window class, we simply need to write the implementation of our ShowNewPrefixPrompt member function to display the dialog. For this, I also followed the official Unreal files, more specifically the UEditorDialogLibrary::ShowObjectsDetailsView.
First, we need to configure the general settings for the window and create the Slate window itself. The code below reminds of ImGui or XAML which is normal, since Slate is Epic's implementation of a UI library.
FString Prefix = "";
if (!FApp::IsUnattended() && !GIsRunningUnattendedScript)
{
FVector2D DefaultWindowSize = FAppStyle::Get().GetVector("WindowSize.Medium");
FVector2D MinSize = FVector2D(Options.MinWidth <= 0 ? DefaultWindowSize.X : Options.MinWidth, Options.MinHeight <= 0 ? DefaultWindowSize.Y : Options.MinHeight);
TSharedRef<SWindow> Window = SNew(SWindow)
.Title(Title)
.SizingRule(Options.bAllowResizing ? ESizingRule::UserSized : ESizingRule::Autosized)
.MinWidth(MinSize.X)
.MinHeight(MinSize.Y)
.ClientSize(Options.bAllowResizing ? MinSize : FVector2D())
.AutoCenter(EAutoCenter::PrimaryWorkArea)
.SupportsMinimize(false)
.SupportsMaximize(false);
// Set Window Contents
// [...]
);
// Add Window To Editor
// [...]
// Handle return
// [...]
}
We set the window contents like so:
// Set Window Contents
TSharedPtr<SPrefixInputDialog> Dialog;
Window->SetContent(SAssignNew(Dialog, SPrefixInputDialog, Window, Options)
.DialogTitle(FText::FromString("New Dictionary Entry"))
.FileName(FText::FromString("File Name: " + FileName))
.ClassName(FText::FromString("Class Name: " + ClassName))
.InputLabel(FText::FromString("Enter Prefix:"))
Finally, we need to register the window with the Editor Instance and, of course, handle the return of the Dialog which, in this case, is the new Prefix:
// Add Window to Editor Instance
GEditor->EditorAddModalWindow(Window);
// Handle return
if (Dialog->WasOkPressed())
Prefix = Dialog->GetPrefixInput();
return Prefix;
If a dictionary registry wasn't found, we immediately call the dialog we just created:
Then we check the return string. If it's empty, we can't register a new prefix correctly, so we throw an error in the Message Dialog and call the Asset Fails node. Otherwise, we update the Dictionary.
This is quite a simple step but it has a catch. When speaking of updating Data Tables in UE5, there is a nice node called "Add Data Table Row". However, that node is broken! If you try to add a new row with it, it simply doesn't happen. This could probably be due to the fact that it doesn't update the asset's package. Because of that, I created my own function, following this tutorial:
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Add Validation Data Table Row", Keywords = "add data table row validation"), Category = "ValidationUtilities")
static bool AddRowToDataTable(const FString& DataTablePath, const FString& RowName, const FCppDictionaryEntry& RowData);
The steps are simple. First we cast the UObject to UDataTable to make sure we're working with the same type. Then we make sure that the row we're trying to add doesn't already exist because that will implicitly overwrite the value. When that is out of the way, we simply called the Modify and AddRow functions:
UObject* Asset = StaticLoadObject(UObject::StaticClass(), nullptr, *DataTablePath);
UDataTable* DataTable = CastChecked<UDataTable>(Asset);
// Add the row to the datatable
if (!DataTable)
{
UE_LOG(LogTemp, Error, TEXT("DataTable is null!"));
return false;
}
FCppDictionaryEntry* ExistingEntry = DataTable->FindRow<FCppDictionaryEntry>(*RowName, TEXT(""));
if (!ExistingEntry)
{
DataTable->Modify();
DataTable->AddRow(*RowName, RowData);
}
//
Next is the important step - we need to save the package! This is also quite easy to do since there is a UEditorLoadingAndSavingUtils::SavePackages function. We use it like so:
// Save the package
UPackage* Package = DataTable->GetPackage();
if (Package == nullptr)
{
UE_LOG(LogTemp, Error, TEXT(" AddRowToDataTable Failed - Package is null!"));
return false;
}
bool Success = UEditorLoadingAndSavingUtils::SavePackages({ Package }, false);
if(!Success)
UE_LOG(LogTemp, Error, TEXT(" AddRowToDataTable Failed - Could not save package!"));
//
return Success;
Now all is left to do is call our newly created node in our Editor Utility Blueprint which will update the data table:
The data table successfully updates in real time and the developer can continue their work. If we run the validation on the same folder again, the validator will mark it as invalid because the asset's name is "tyhgrfed" instead of "BS_tyhgrfed".
Creating this tool was extremely insightful regarding the way Unreal Engine operates. Extending and/or customizing already existing technologies is one of the most vital skills a programmer can possess and I'm happy that the lessons I learned from this challenge increased my proficiency in this regard. I believe the most part of this feature was combining C++ and scripting (in this case blueprints) together. Most of the time, we don't have to write our own logic from scratch and the right tool for the job already exists - the key is finding it and using it correctly.