UE plug-in and tool development:Commandlet

UE插件与工具开发:Commandlet

When using the Unreal Engine development tool, a significant number of tasks involve resource processing and data export needs. These tasks need to be performed frequently and automatically, often integrated into a CI/CD system.

In the specific implementation, the Commandlet mechanism of UE is utilized to drive the engine in a command-line manner, performing custom behaviors.

Taking the Commandlet features supported in the plugin I developed as an example:

  1. HotPatcher: Export basic package information, pack patches
  2. ResScannerUE: Incremental scanning of changed resources
  3. HotChunker: Standalone packaging of Chunk
  4. libZSTD: Training Shader dictionaries
  5. ExportNavMesh: Export NavMesh data

Commandlet allows for easier integration into CI/CD to achieve automation.

In this article, I will primarily introduce the Commandlet mechanism of UE, analyze its implementation principles, and provide some development tips and insights from my development process.

Additionally, this is the second article in my UE Plugin and Tool Development series, which will continue to be updated, so stay tuned.

What is Commandlet

The UE Commandlet is a mechanism that allows driving the engine via command line, similar to traditional CLI programs:

arg_printer.cpp
1
2
3
4
5
int main(int argc,char* agrv[]){
for (int i = 0; i < argc; i++){
cout << argv[i] << endl;
}
}

This can be invoked via the command line, with parameters provided:

1
./arg_printer -test1 -test2

Commandlet provides such a calling mechanism. However, the program that needs to be invoked is the engine, and it is necessary to pass the uproject file path and other parameters.

Taking the Cook Commandlet as an example:

1
UE4Editor-cmd.exe D:\Client\FGame.uproject -run=cook

This form starts the command-line engine without launching the Editor, instead executing the defined behavior.

Creating Commandlet

To create a Commandlet for a project or plugin, it is usually added in a Module with the type Editor, as the logic for executing Commandlet rarely involves runtime; it is executed in the engine environment.

To create a custom Commandlet, a UObject class derived from UCommandlet must be created:

ResScannerCommandlet.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#pragma once  
#include "Commandlets/Commandlet.h"
#include "ResScannerCommandlet.generated.h"

DECLARE_LOG_CATEGORY_EXTERN(LogResScannerCommandlet, All, All);

UCLASS()
class RESSCANNER_API UResScannerCommandlet : public UCommandlet
{
GENERATED_BODY()

public:
virtual int32 Main(const FString& Params) override;
};

The Main function is executed when the Commandlet is started via the -run= command, similar to the main function in pure C++.

However, it should be noted that since Commandlet is essentially a complete engine environment, it will invoke the startup of registered modules during execution, which means that a lot of pre-logic needs to be executed, and by the time it reaches the Main function, it will be in a complete engine state.

Custom operations can be performed within this function; at this point, it has the complete engine environment to handle data export or resource processing.

Running Commandlet

As mentioned earlier, running a Commandlet needs to be done using the following command format:

1
UE4Editor-cmd.exe PROJECT_NAME.uproject -run=CMDLET_NAME

When starting a Commandlet in a project, the project path must be specified as it is needed to load the project’s and plugin’s modules.

The -run= parameter specifies the name of the Commandlet to execute, which is directly related to the name of the previously created class derived from UCommandlet: for example, UResScannerCommandlet has a Commandlet name of ResScanner, following the rule of removing the leading U and trailing Commandlet.

When the engine receives the -run= parameter, it searches for the UClass and automatically appends the Commandlet suffix:

1
2
// Token -- XXXXCommandlet
CommandletClass = FindObject<UClass>(ANY_PACKAGE, *Token, false);

After obtaining the UClass, an instance is created, and its Main function is called:

Runtime\Launch\Private\LaunchEngineLoop.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
UCommandlet* Commandlet = NewObject<UCommandlet>(GetTransientPackage(), CommandletClass);
check(Commandlet);
Commandlet->AddToRoot();

// Execute the commandlet.
double CommandletExecutionStartTime = FPlatformTime::Seconds();

// Commandlets don't always handle -run= properly in the command line so we'll provide them
// with a custom version that doesn't have it.
Commandlet->ParseParms(CommandletCommandLine);
#if STATS
// We have to close the scope, otherwise we will end with broken stats.
CycleCount_AfterStats.StopAndResetStatId();
#endif // STATS
FStats::TickCommandletStats();
int32 ErrorLevel = Commandlet->Main(CommandletCommandLine);
FStats::TickCommandletStats();

RequestEngineExit(FString::Printf(TEXT("Commandlet %s finished execution (result %d)"), *Commandlet->GetName(), ErrorLevel));

Note that all modules are started before entering the Commandlet’s Main function, meaning the module’s StartupModule is executed before Main.

The call stack for the engine’s execution of the Commandlet Main function:

Context

Detecting Commandlet

From the above content, it is important to remember two key points:

  1. Commandlet is located within the Editor module.
  2. After entering Main, Commandlet has the complete engine environment.

Since Commandlet is located in the Editor module, it may invoke other functions within the module. To distinguish between Editor startup and Commandlet startup, the following function can be used for detection:

1
2
3
4
if (::IsRunningCommandlet())
{
// do something
}

Receiving Parameters

The prototype of the Commandlet function is:

1
int32 Main(const FString& Params)

The parameters passed in do not include the engine path and project path.

For example, running the command:

1
UE4Editor-cmd.exe G:\Client\FGame.uproject -skipcompile -run=HotChunker -TargetPlatform=IOS

The received parameters would be:

1
-skipcompile -run=HotChunker -TargetPlatform=IOS

By parsing these command-line parameters, special behaviors can be executed in Commandlet. Taking HotPatcher as an example, a configuration file can be specified via -config=, which will be read in Commandlet and executed according to the configuration file.

Driving Tick

Commandlet is, by default, an independent procedural behavior and does not drive the engine Tick; it behaves like a regular function. Once all code execution is complete, it requests to exit the engine.

Runtime\Launch\Private\LaunchEngineLoop.cpp
1
2
3
int32 ErrorLevel = Commandlet->Main(CommandletCommandLine);  
// ...
RequestEngineExit(FString::Printf(TEXT("Commandlet %s finished execution (result %d)"), *Commandlet->GetName(), ErrorLevel));

However, some needs require Tick to be driven in Commandlet to execute logic, such as frame-sharing cooking in HotPatcher, to avoid processing too many resources within a single frame, all of which need to be executed in a complete engine Tick environment.

To meet this need, I encapsulated a function that drives Tick within Commandlet:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
void CommandletHelper::MainTick(TFunction<bool()> IsRequestExit)  
{
GIsRunning = true;

FDateTime LastConnectionTime = FDateTime::UtcNow();

while (GIsRunning &&
// !IsRequestingExit() &&
!IsRequestExit())
{
GEngine->UpdateTimeAndHandleMaxTickRate();
GEngine->Tick(FApp::GetDeltaTime(), false);

// Update task graph
FTaskGraphInterface::Get().ProcessThreadUntilIdle(ENamedThreads::GameThread);

// Execute deferred commands
for (int32 DeferredCommandsIndex = 0; DeferredCommandsIndex < GEngine->DeferredCommands.Num(); DeferredCommandsIndex++)
{
GEngine->Exec(GWorld, *GEngine->DeferredCommands[DeferredCommandsIndex], *GLog);
}
GEngine->DeferredCommands.Empty();

// Flush log
GLog->FlushThreadedLogs();

#if PLATFORM_WINDOWS
if (ComWrapperShutdownEvent->Wait(0))
{
RequestEngineExit();
}
#endif
}
//@todo abstract properly or delete
#if PLATFORM_WINDOWS
FPlatformProcess::ReturnSynchEventToPool(ComWrapperShutdownEvent);
ComWrapperShutdownEvent = nullptr;
#endif

GIsRunning = false;
}

This function is called in the Main function and a callback is passed to request to exit Tick logic each time Tick is requested:

1
2
3
4
5
6
7
8
9
if (IsRunningCommandlet())  
{
CommandletHelper::MainTick([&]() -> bool
{
bool bIsFinished = true;
// State control
return bIsFinished;
});
}

In HotChunker, I implemented a mechanism to package Chunk in frames using this feature.

Note that in older engine versions, driving the engine within Commandlet has bugs, causing crashes in FSlateApplication, as Commandlet is a complete engine environment but does not include Editor related aspects. When the engine is driven within Commandlet, it triggers execution of FSlateApplication, leading to null access.

Fixing FSlateApplication Bugs

In Commandlet situations, FSlateApplication is not initialized, so checks need to be performed in modules used.

1
2
Engine/Source/Editor/UnrealEd/Private/EditorEngine.cpp
Engine/Source/Editor/UnrealEd/Private/AssetThumbnail.cpp

UnrealEd/Private/EditorEngine.cpp
1
2
3
4
5
6
7
8
9
10
void FAssetThumbnailPool::Tick(float DeltaTime)
{
// ++[lipengzha] fix commandlet tick crash
if (!FSlateApplication::IsInitialized())
{
return;
}
// --[lipengzha]
// ...
}

UnrealEd/Private/AssetThumbnail.cpp
1
2
3
4
5
6
7
8
9
10
bool UEditorEngine::AreAllWindowsHidden() const
{
// ++[lipengzha] fix commandlet tick crash
if (!FSlateApplication::IsInitialized())
{
return false;
}
// --[lipengzha]
// ...
}

After making these modifications, it is possible to drive the engine Tick normally within Commandlet.

Accelerating Startup

As mentioned earlier, starting a Commandlet in UE essentially loads the engine and the dependent modules of the project and executes StartupModule. However, in some instances, certain features may not be needed and could be time-consuming, so they can be disabled on-demand.

The startup time of each module’s StartupModule can be analyzed to identify and dynamically toggle modules that take a long time to initialize based on command-line parameters.

I detailed how to analyze the loading times of modules in the article UE Multi-phase Automated Resource Inspection Scheme, writing optimization strategies targeting time-consuming LiveCoding/AssetRegistry.

Taking the resource scanning of ResScanner as an example, the overall startup time of the Commandlet engine can be reduced to approximately 20 seconds, and the resource scanning process can be completed in less than 20 seconds, making the perceived time for executing Commandlet during submission in git hook scenarios insignificant.

Intervening in a Commandlet Process

Taking Cook as an example, it invokes a CookCommandlet to execute. By default, the engine does not provide hooks for us to intervene in the various phases of Cook. If modifications are needed during the packaging process, one would have to manipulate UAT to break down the build process and maintain each phase independently.

However, this approach can be cumbersome considering the following needs:

  • Train Shader dictionaries after Cook is completed and replace the original ShaderLibrary with the one compressed using the dictionary.

Under default circumstances, achieving this would require stopping the packaging process after Cook, handling the custom process first, and then proceeding to the UnrealPak phase.

This process is quite tedious; if we want to automatically execute commands after Cook, we can use another method to intervene in the Commandlet execution process.

From earlier discussions, we know that Commandlet execution also involves the execution of module StartupModule and ShutdownModule. To intervene in the Commandlet execution, we can utilize this feature.

Taking the custom processing flow invoked by the engine after Cook as an example, this requirement can be broken down as follows:

  1. Determine whether to run in CookCommandlet.
  2. Execute the processing before the engine exit after Cook is completed.

Regarding the first point, we can parse the startup command line parameters and check if the -run= token is Cook, enabling us to ascertain whether we are in CookCommandlet:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
bool CommandletHelper::GetCommandletArg(const FString& Token, FString& OutValue)  
{
OutValue.Empty();
FString Value;
bool bHasToken = FParse::Value(FCommandLine::Get(), *Token, Value);
if (bHasToken && !Value.IsEmpty())
{
OutValue = Value;
}
return bHasToken && !OutValue.IsEmpty();
}
bool CommandletHelper::IsCookCommandlet()
{
bool bIsCookCommandlet = false;

if (::IsRunningCommandlet())
{
FString CommandletName;
bool bIsCommandlet = CommandletHelper::GetCommandletArg(TEXT("-run="), CommandletName);
if (bIsCommandlet && !CommandletName.IsEmpty())
{
bIsCookCommandlet = CommandletName.Equals(TEXT("cook"), ESearchCase::IgnoreCase);
}
}
return bIsCookCommandlet;
}

For the second point, we can register a callback for OnPreEngineExit in the StartupModule of the engine, and we avoid using ShutdownModule since this is executed after the engine has entered the exit state, at which point several modules have already been shut down, and executing in this flow could cause various issues.

1
2
3
4
5
6
void FlibZSTDEditorModule::StartupModule()  
{
#if ENGINE_MAJOR_VERSION > 4 || (ENGINE_MAJOR_VERSION == 4 && ENGINE_MINOR_VERSION > 24)
FCoreDelegates::OnEnginePreExit.AddRaw(this, &FlibZSTDEditorModule::OnPreEngineExit_Commandlet);
#endif
}

After the engine requests to exit, this callback will trigger automatically, and we can perform checks and execute within it:

1
2
3
4
5
6
7
8
9
10
11
12
void FlibZSTDEditorModule::OnPreEngineExit_Commandlet()  
{
FCoreUObjectDelegates::PackageCreatedForLoad.Clear();

#if ENGINE_MAJOR_VERSION > 4
FScopeRAII ScopeRAII;
#endif
if (CommandletHelper::IsCookCommandlet())
{
// do something
}
}

Note that this callback will be triggered during engine exit for both Editor and Commandlet startups, so checks should be performed to determine if it is executing in the correct environment.

This approach allows for intervention in any Commandlet without modifying the engine. It is not limited to engine exit timings; by combining the Module’s LoadingPhase, many types of insertion flows can be achieved, allowing for extended creativity.

In my previous articles, A Flexible and Non-intrusive Basic Package Split Scheme and Resource Management: Reshaping the UE Package Split Scheme, the HotChunker was also implemented using this method, automatically invoking HotChunker to package chunks after CookOnTheFlyServer execution, all performed non-intrusively to the engine.

Conclusion

This article explored the creation, running, environmental detection, engine Tick driving, analysis of time consumption, startup acceleration, and intervention in Commandlet processes.

Commandlet is a technology widely used in tool development, and its appropriate application can easily integrate into CI/CD systems to achieve automation, reduce manual intervention, and improve efficiency.

The article is finished. If you have any questions, please comment and communicate.

Scan the QR code on WeChat and follow me.

Title:UE plug-in and tool development:Commandlet
Author:LIPENGZHA
Publish Date:2023/03/23 13:46
Word Count:8.6k Words
Link:https://en.imzlp.com/posts/27475/
License: CC BY-NC-SA 4.0
Reprinting of the full article is prohibited.
Your donation will encourage me to keep creating!