When developing UE plugins, we often provide a large number of configurable parameters for flexible control, used to manage the specific execution logic and behavior of the plugins.
This article is the eighth in my UE Plugin Development Series, and will introduce my thoughts and implementations regarding the configurability of plugins during the development process, along with practices in project configuration, task configuration, dynamic parameter replacement, etc., to make the plugin configuration process as flexible and easy to use as possible.
Project Configuration
Global Configuration Creation
Plugins can create their own configurations under Project Settings
- Plugins
to control their behaviors.
Taking HotPatcher as an example, under Project Settings
- HotPatcher
, you can create:
In the project settings, the logic is to control the plugins globally, which means the global parameters of the plugin running in the current engine environment, and the configured values are stored in Config
, which can be submitted to the repository as the default configuration for the entire project.
To create this, you need to first create a class of UObject
:
1 | UCLASS(config = Game, defaultconfig) |
You need to add reflection markers to all the configuration parameters you want to expose, and add Config
related identifiers to the UCLASS
, so that the edited ini will be stored under [PROJECT_DIR]/Config
. However, which ini it is specifically stored in depends on the situation you decide. If it is purely related to the Editor, it can be placed under Editor
.
After creation, it also needs to be registered with the engine. You can register it through SettingsModule
when the engine starts. Again, taking HotPatcher as an example:
1 | if (ISettingsModule* SettingsModule = FModuleManager::GetModulePtr<ISettingsModule>("Settings")) |
The logic here is to bind the CDO
of the class UHotPatcherSettings
to Project Settings
- Plugins
- Hot Patcher
.
In the project settings, clicking on Hot Patcher
will list all configuration parameters according to the CDO
.
Configuration Saving
After modifying the values, it will trigger UObject::SaveConfig
, saving the current CDO values to the corresponding ini:
Saved configuration:
1 | [/Script/HotPatcherEditor.HotPatcherSettings] |
This is a typical CDO default parameter configuration. When the engine starts, it creates CDO and reads values from the ini specified by UClass and writes them in.
This enables the storage and modification of the plugin’s universal configuration in the project.
Accessing Configuration
How to use it?
Since the object we use to save the plugin configuration is the CDO, if we want to access the real-time values in the engine, we can obtain them via CDO:
1 | const UHotPatcherSettings* Settings = GetDefault<UHotPatcherSettings>(); |
Then, you can retrieve the corresponding values just like accessing ordinary properties.
Taking bWhiteListCookInEditor
from the Hot Patcher configuration as an example:
It controls the listing of specified whitelisted platforms only in the Cook And Pak
.
This way, you can conveniently manage parameters for global behaviors of the plugin.
Task Configuration
The previous section mentioned that global configurations could be created for plugins to control general behaviors.
On this basis, sometimes we need the plugin to execute different tasks, and these different tasks may also use different parameters. Therefore, a configurable logic for task execution is typically required for the plugin.
PropertyEditor Creation
Again using HotPatcher as an example, opening byPatch
on the main interface will pop up a configuration panel:
This panel is used to edit task execution configurations, and it provides three options in the upper right corner: Import
/Export
/Reset
, to facilitate importing, exporting, and resetting the current configuration.
This allows each execution task to have its own configuration file, which can be selected when execution is required.
So how is this achieved?
Also taking HotPatcher as an example, you need to first create a reflected structure and declare all properties that need to be accessed in the configuration:
1 | USTRUCT() |
All options for the execution configuration are defined within it, and during actual execution, an instance of this structure is constructed from the configuration file to run.
After creating the structure, a Slate control needs to be created to contain our configuration interface. Essentially, it is necessary to bind this reflected structure with the Editor to allow editing.
You can use PropertyModule
to create a SettingView:
1 | void SHotPatcherPatchWidget::CreateDetailsView() |
And bind this SettingView
to this reflected structure and instance, realizing that when values are modified in the Editor, the corresponding memory instance will also be modified.
After creating the SettingView
, you also need to place its control into the specified Slate container control:
Thus, when opening the Slate control we created, all reflected property values of the reflected structure (FExportPatchSettings
) will be listed in the form of the engine’s PropertyEditor
, similar to the configuration interface of HotPatcher:
Modifying values through this configuration interface will synchronize the changes to the bound FExportPatchSettings
object instance, and our read and save operations for the configuration will also be conducted through this reflected structure instance, which will be introduced in detail later.
Configuration Serialization
As mentioned earlier, we can bind the property window to the reflected structure instance in memory through PropertyEditor
. For our needs of reading/saving/resetting, we only need to modify the instance of this structure.
How to do this?
Saving
For saving the configuration, we can save the values of a reflected structure instance as a json structure, and the engine itself provides such capabilities.
First we can convert a reflected structure to a JsonObject:
1 | FExportPatchSettings StructIns; |
Then, we can convert this JsonObject to a Json string:
1 | FString JsonString; |
Once we have the string, it can be saved, and this is the configuration file we want to store:
1 | FFileHelper::SaveStringToFile(JsonString,*SavePath); |
Loading
When saving the configuration file, for loading the configuration file, it actually involves two steps: refreshing the reflected structure instance properties from the json file and updating the PropertyEditor display values.
For deserializing from json to UStruct, you can also do this using associated functions from FJsonSerializer
:
- Read the string from the json file, then deserialize it into a JsonObject.
- Use the JsonObject to modify the values of the reflected structure instance.
1 | FExportPatchSettings StructIns; |
This way, the configuration in json can be overwritten onto the instance.
Then it’s also necessary to refresh the PropertyEditor
‘s SettingView
to ensure that the display is consistent with the actual values:
1 | SettingsView->GetDetailsView()->ForceRefresh(); |
Resetting
After the introduction of the previous two parts, the reset logic becomes even simpler: you just need to reset the reflected structure instance to default constructed values, and then refresh the SettingView.
1 | StructIns = FExportPatchSettings{}; |
This achieves the most basic reset logic. However, HotPatcher does something a bit more complex, resetting to the preset configuration values in Project Settings
, but the basic principle is consistent.
Dynamic Parameter Replacement
When we are able to export and import configurations, they are not just used under the Editor; we usually also provide cmdlets for automation execution in CI systems.
For multiple configurations, allowing cmdlets to specify configuration files to control the execution tasks is a good choice.
Like those provided by HotPatcher
and ResScanner
:
1 | -run=HotPatcher -config=config.json |
Although convenient, there is a pain point: if two tasks to be executed share most parameters but differ in a few, it would be troublesome to create an independent configuration file for each case.
In such cases, we need to provide a logic that allows for dynamically controlling certain parameter values so that we can provide a universal configuration for a batch of similar logic and dynamically modify the necessary values via the command line.
Based on this need, I wrote a template function that allows you to overwrite reflected properties by specifying a structure instance along with a Name-Value
key pair: ReplaceProperty.hpp
You just need to specify structure properties after the cmdlet execution parameters and it supports multi-level structures, such as:
1 | -run=HotPatcher -config=config.json -bByBaseVersion=true -BaseVersion.filePath="" -versionid="0.1.00" -savePath.path="[OUTPUT_DIR]" |
First, read the config and deserialize it, then execute the parameter overwriting, enabling flexible dynamic parameter control.
In fact, not only cmdlet configurations can do this, but also CDO configurations in project settings can be dynamically replaced by parameters, which is similar to what is introduced here, so it will not be elaborated further.
Conclusion
This article introduces several ways to add configurability to plugins in UE, these methods greatly enhance the flexibility of plugins. Configurability and flexible control are forever the themes pursued in plugin development, and tools must continue to evolve.
In addition to the methods introduced above, you can also customize the PropertyEditor to decouple the data types of the configurations displayed in the interface from the actual stored data types. For instance, a string could be stored at the underlying structure, but the configuration interface can process and display it differently, as written in a previous article (Customizing Property Panels in Unreal Engine).
There are still several articles in preparation for the plugin and tool development series; the next article will introduce how to add custom process control and dynamic extension capabilities to plugins, so stay tuned.