When developing plugins, as plugin functionalities become increasingly complex, it’s often necessary to provide some extensibility support to implement custom extended features that enrich the plugin’s capabilities, and also to facilitate users in customizing projects without modifying the core program.
In previous articles (HotPatcher’s Modular Refactoring and Development Planning), the support for HotPatcher and extension modules, and how to develop a new module based on HotPatcher were introduced. This article primarily details the specific implementation methods within UE.
Simply put, it means building one’s own extension system on top of UE’s existing plugin system. This article will introduce some of my thoughts and techniques on improving plugin extensibility, and how to conveniently decouple these extension features from the main plugin body for easier maintenance and management.
Core Refactoring
Based on my experience with HotPatcher, extension Mods typically fulfill the following requirements:
- Listen to the execution flow to perform actions. For example, after HotPatcher packaging is complete, the patch can be pushed directly to a mobile device.
- Extend the execution flow to perform additional data recording and processing within the default execution framework. For example, during the HotPatcher hot-update process, the current git commit information of each local repository is recorded.
- Logic replacement, replacing the logic within the plugin with custom logic as needed.
- Feature encapsulation. In this case, it won’t affect the core plugin’s flow and logic, but it will wrap the core plugin’s features with a special layer of encapsulation as needed. For instance, the GameFeaturePacker Mod encapsulates HotPatcher‘s packaging capabilities specifically for GF packaging.
Therefore, based on these requirements, some refactoring of the base plugin core is needed before proceeding with extensibility modifications:
- Configuration-driven refactoring: Make the plugin’s task execution completely configuration-driven, allowing extension modules to override configuration logic to implement custom features. For example, the GameFeaturePacker module I previously implemented encapsulates HotPatcher‘s packaging capabilities, but adds specific asset and file packaging logic for GF plugins.
- Execution logic phase splitting: Divide the plugin’s process into multiple distinct phases. This part is crucial, as splitting allows us to easily intervene in the execution process of each phase, achieving the goal of extensibility.
- Context support: Ensure a unified context (
Context) throughout the entire execution process. In extension flows, data within the context is often modified (e.g., to further process the results of HotPatcher‘s diff analysis, one would need to retrieve the diff results from the context and modify the context after processing). - Critical logic replacement: Some behaviors within the plugin are not hardcoded but can be controlled dynamically or through configuration, such as resource analysis and diff analysis logic in HotPatcher.
- Allow recording additional data or files: When a module is developed, it might produce data or files not directly required by the core, which need to be collected centrally within the core’s process.
- Module isolation: The behavior of extension modules themselves needs to be well-abstracted to prevent direct dependencies of the core on modules, thus avoiding cyclical plugin dependencies in UE.
Once a plugin has undergone the above refactoring, the core will have a clearer execution architecture and be easily extensible. Next, the specific implementation of the extensible parts will be introduced.
Our goals are to achieve:
- The Mod is a completely independent UE plugin. Installing it requires no modifications to the core; simply place the Mod’s plugin into the project.
- The Mod has pluggable behavior, and its impact on the core is controllable, allowing it to be disabled via configuration at any time to prevent a Mod issue from affecting the entire execution process.
- The Mod has completely independent configuration and execution flows.
Mod Registration
Sometimes modules add completely independent configurations, such as extension modules for HotPatcher:
Each module’s configuration is independent and supports Import/Export/Reset configuration mechanisms.
Because the main interface is written by the HotPatcher core, and each module’s configuration logic and Slate widgets are written within its own module, and each module may also have multiple configuration scenarios.
Therefore, it’s necessary to define good abstractions for modules within the core. Each module’s behavior is abstracted into an Action, and each extension can have multiple Actions:
1 | using FRequestWidgetPtr = TFunction<TSharedRef<SHotPatcherWidgetInterface>(TSharedPtr<FHotPatcherModContextBase>)>; |
For each Mod, the following information needs to be recorded:
- Name
- Whether it’s a built-in Mod for the core
- Current version number
- Mod description
- Mod URL (documentation, etc.)
- Update URL (for checking Mod updates)
- Minimum core version that this Mod depends on
- List of Actions supported by the Mod
This way, each Mod’s information can be clearly recorded.
Each Mod adds executable configuration logic by registering an Action, allowing RequestWidgetPtr to dynamically request the Mod’s Widget; it’s just a callback function.
Taking HotPatcher as an example, when a Mod’s Action is selected from the list in the upper right corner, the corresponding control for that Action will be dynamically requested and populated into the main interface:
At the same time, logic to register an Action also needs to be written when the Mod plugin’s Module starts up. This also depends on the base core plugin’s LoadingPhase being earlier than that of the extension Mod.
The core provides registration functions
1 | struct HOTPATCHEREDITOR_API FHotPatcherActionManager |
The Mod needs to call registration upon plugin startup:
1 |
|
By passing this information, Mod registration and the ability for the core to dynamically receive it are achieved.
Through this implementation mechanism, it is achieved that simply placing the Mod’s plugin into the project allows it to be recognized by the core, and Mods can then be selected for configuration.
Flow Extension
In the preceding Core Refactoring sub-section, I introduced the need for the core to split execution phases and to have a unified Context, which is crucial for flow extension.
Delegates
Once we have split the execution phases, we can add flow control points before and after each phase. The conventional approach is to add Delegate proxies to notify which phase has been reached.
1 | DECLARE_MULTICAST_DELEGATE_TwoParams(FNotificationEvent, FText, const FString&) |
By listening to this global delegate, one can monitor which stage of execution has been reached and receive the context from the parameters for necessary processing.
In fact, a large number of processes within the engine are also built on Delegates, such as
FGameDelegates,FCoreDelegates, etc. They are similar approaches, triggered at specific times during engine execution to perform necessary actions.
UClass
Typically, global delegates are always called during execution, and callbacks are received as long as there is a binding. This is not logic that can be dynamically controlled for hot-swapping, so it is generally only used for common logic, such as monitoring when an entire task has completed to perform subsequent processing.
So, how can dynamic and pluggable configuration be achieved?
This can be achieved through UClass. A base class can be defined for the callback function prototypes of each stage, and each Mod can inherit from this base class to override virtual functions:
Then, this Class can be specified in the configuration, and an instance of it can be constructed at runtime to execute our overridden logic.
For example, in HotPatcher, a Mod that supports creating binary patches:

And a Mod for customizing the resource analysis process (HotChunker integrated into the hot-update flow):
Furthermore, by utilizing the PropertyCustomization mechanism (see article Property Panel Customization in Unreal Engine), overridden parameters can be added to each specified Class, allowing for more flexible control over behavior:
And property controls can be automatically created based on the parameter type, with strings stored at the bottom layer:
This way, which extension capabilities to enable can be determined based on the configuration file.
Extension Case Study
In addition to what was introduced earlier, in the hot-update process, we also added a check process for hot-update assets based on ResScanner. During each hot-update, the specifications of all participating assets are checked to prevent problematic resources from being introduced into the live environment.
In HotPatcher, this can be achieved by specifying ResScanner‘s adapter:
The implementation logic is that after the hot-update patch is created, all assets within the patch are retrieved, and then all rules are checked:
And the results will be output to the directory of the current patch.
Based on this extension pattern, various plugins can be combined very conveniently to achieve a 1+1>2 effect, and without writing additional control flows, everything can be realized within the same execution process.
Logic Replacement
In addition to the flow extension mentioned in the previous sub-section, there is also a need to replace certain built-in logic.
For example, choosing a binary patch algorithm in HotPatcher, modifying the default diff analysis process, etc., all aim to replace the default flow with a new one.
ModularFeature
Long ago, I wrote an article about integrating a new compression algorithm into UE: ModularFeature: Integrating ZSTD Compression Algorithm for UE4.
It utilized the ModularFeature mechanism in the engine, which allows providing a base interface class, implementing it separately, and then registering it with the engine.
When the corresponding capability is needed, the relevant ModularFeature instance can be obtained from the engine to execute the logic.
For example, the base MF interface provided by HotPatcher for creating binary patches needs to inherit from IModularFeature:
1 | struct IBinariesDiffPatchFeature: public IModularFeature |
Then, an HDiffPatch algorithm was extended in the Mod:
1 | class FHDiffPatchModule : public IBinariesDiffPatchFeature,public IModuleInterface |
It needs to be registered with the engine when the UE Module starts:
1 | void FHDiffPatchModule::StartupModule() |
When running, to access ModularFeature, all registered Features can be obtained from the engine:
1 | TArray<IBinariesDiffPatchFeature*> ModularFeatures = IModularFeatures::Get().GetModularFeatureImplementations<IBinariesDiffPatchFeature>(BINARIES_DIFF_PATCH_FEATURE_NAME); |
Then, this array can be iterated through, and the desired Feature can be found using GetFeatureName.
This way, any algorithm can be extended independently, decoupled from the core plugin.
It is more suitable for specifying general mechanisms that are independent of business logic, such as the engine’s support for new compression algorithms, which is also implemented through ModularFeature.
UClass
Besides ModularFeature, the flow extension sub-section already mentioned that UClass can be used for extension. In fact, flow replacement is similar; it also requires first abstracting an independent interface class, then inheriting and overriding it in the Mod.
Taking HotPatcher‘s diff process as an example, I created an abstract interface class:
1 | UCLASS(Abstract) |
Then, based on the default analysis process, I wrote a Default class:
1 | UCLASS() |
And made it specifiable in the configuration:
1 | UPROPERTY(EditAnywhere) |
Accessing the specified Class,
1 | // 获取是否指定有效的UClass,否则使用默认的 |
After getting the Class, create an instance, override instance parameters, and call the predefined interface:
1 | UPatcherCustomDiffGenerator* CustomDiffGenerator = Cast<UPatcherCustomDiffGenerator>(NewObject<UObject>(Context.GetProxy(),CustomDiffGeneratorClass.Get())); |
This way, replaceable logic is implemented within the flow.
To modify the logic, one only needs to select the corresponding UClass in the configuration:
Module Management
Based on the introductions in the preceding sub-sections, we can now achieve relatively rich extensions for the core plugin. Thus, how to conveniently manage Mods also needs to be considered.
Currently, I manage them based on git submodule (see Mods), which is quite convenient:
- Each submodule is an independent git repository, allowing for independent maintenance and extension.
- Separate permission management: Since each repository is independent, access permissions for each Mod can be precisely controlled.
- The plugin core can record the git hash of each Mod, which can be used to specify the default submodule version, facilitating iteration.
For content related to git submodule, you can refer to an article I wrote previously: Git Quick Start Guide.
Conclusion
This article introduced some of my experiences and ideas in modular plugin development during the process of developing HotPatcher, aiming to prevent all functionalities from piling up in a single plugin and to achieve flexible extensibility.
It allows for relatively convenient feature extension, combination of functionalities from different plugins, and modular management and iteration methods. By decoupling implementations as much as possible, it can serve as an implementation reference for building a Mod extension system on top of UE’s plugin system.
After long-term development and expansion, HotPatcher‘s functionalities are no longer limited to resource packaging and hot-updates. Instead, it encompasses a complete resource management solution including resource sub-packaging, hot-updates, package size optimization, resource auditing, and automated resource processing.