I’ve been experimenting around this for mod support considerations.
My goal was to set up a BP project that supports mods, relying entirely on default behavior of plugin manager & asset registry, with no C++ involved.
I finally got it to work properly, after running into a few traps and working them out, which I’m gonna document below. This was on UE 5.1
Note that for UE4 projects, a BP-only implementation will be severely restricted due to limited asset registry methods. More precisely, UE5 provides a node GetBlueprintAssets
which is designed to find blueprint subclasses, and the ability to cast an UObject to UClass which is necessary after sync/async loading a BP Asset. UE4 blueprints come short on both aspects.
Basegame Setup
I made a base game project, with a stub blueprint base class (BP_Base), a basic level and a material for it.
Then I created BP_Spawner blueprint which uses asset registry to find all blueprints child of BP_Base, and spawn them. This is the spawner BP code :
I added BP_GameDerived as a child of BP_Base, which doesn’t do much other than confirming my spawner works.
Base game is packaged with default settings (minus IOStore, I haven’t experimented with that yet).
Mod setup
Then I made another project in which I copied BP_Base. This is intended to be the “SDK” for mod support.
Using project launcher I packaged with a first profile “SDK” which will contain all “shared” assets, that are in the base game, and should not be cooked in mods. It will serve as the reference version :
also in Packaging settings :
Then I created a new Content-Only plugin called Mod1, via the standard plugin creation window.
In plugin contents I created a new material, and another child of BP_Base (BP_ModDerived).
In the new class I set up a timer that selects all meshes and makes them blink between the new material and default material every second.
Using Project Launcher I set up second profile “Mod1” which in turn uses SDK as a reference version to package a DLC
note: error in image, should be “SDK” instead of “DLC” in the field above
After packaging, I can confirm the PAK file only contains intended content and does not conflict with any main game file :
Display: Mount point ../../../ModProject/Plugins/Mod1/
Display: "AssetRegistry.bin"
Display: "Content/BP_ModDerived.uasset"
Display: "Content/BP_ModDerived.uexp"
Display: "Content/M_Mat.uasset"
Display: "Content/M_Mat.uexp"
Shipping
All of this was pretty straightforward. After this I ran into multiple “traps” before successfully getting the basegame to detect the mod (and its contents) properly.
The packaged game file structure looks like this
GameProject.exe
Engine/...
GameProject/
- Binaries/...
- Content/Paks/GameProject-Windows.pak
The packaged mod file structure looks like this
ModProject/
- Plugins/
- Mod1/
- Mod1.uplugin
- Content/Paks/Windows/Mod1ModProject-Windows.pak
There are a few things to note.
Assets in the mod pak lie at ../../../ModProject/Plugins/Mod1/Content/...
which means they can only be loaded if the base game mounts a custom path towards them. They are not in /Game or any other default virtual path. If you simply drop the PAK alongside main game’s PAK, it will be loaded, but its assets won’t be reachable.
It may cross your mind that, if your ModProject has the same name as GameProject, and you package mod assets in the /Game folder, then you can drop the mod PAK alongside main game PAK and the paths would work out. That is partially true. Assets may be reachable with a StaticLoadObject, but mod pak asset registry is still at /Plugins/Mod1/AssetRegistry.bin
and will not be loaded. You might also want to try not using a plugin at all, so that the asset registry ends up in /Game/AssetRegistry.bin
. In this case it will conflict with the main game’s asset registry and only one of them will be loaded, so this is not a good option.
PluginManager automatically mounts virtual paths for plugins contents, in the following form :
/PluginName
→ ../../../GameProject/Plugins/PluginName/Content
So if we follow a proper plugin approach, we should get a proper path towards both the mod asset registry and its assets.
PluginManager only enables plugins specified in the .upluginmanifest
file, which is generated and paked automatically for the basegame (not modifiable). However you can manually create additional .upluginmanifest files in the packaged GameProject/Plugins/ folder and they will be read as well.
I failed to get the -DLCPakPluginFile switch to create a .upluginmanifest automatically as mentioned in the above primer.
PluginManager will also automatically load all plugins located in the GameProject/Mods/ folder, however when doing so it will expect asset paths in the Pak to match asset paths on disk. This removes the need to create .upluginmanifest, but you must package the mod as a mod instead of plugin and use the same project name as the base game (more details below).
Mods must be packaged without shared shader archive
The base game can have this setting enabled or not, it should not make any difference.
In the end, I’ve got two working approaches, detailed below.
1. Mods as plugins
The advantage of this approach (compared to #2) is that you can work around the ModProject having a different name than the GameProject.
If you simply drop the packaged plugin into the basegame’s plugin folder, it will not load your plugin, as the game relies on a .upluginmanifest
file that does not specify to load this specific plugin.
You can manually create one (or multiple) new manifest(s) in the Plugins/ folder. They must not be in a subfolder. They must not conflict with the base game’s upluginmanifest, which is in the base game’s PAK at path GameProject/Plugins/GameProject.upluginmanifest
.
Create for example GameProject/Plugins/Mod1.upluginmanifest
with the following format :
{
"Contents": [
{
"File": "../../../ModProject/Plugins/Mod1/Mod1.uplugin",
"Descriptor": {
"FileVersion": 3,
"CanContainContent": true
}
}
]
}
Descriptor
is essentially a copy of what you have in the .uplugin file. I only included what I believe is necessary. The other fields that are usually present do not seem necessary.
The File
field is very important here, as this tells the PluginManager where to find the plugin files, and this is where the plugin virtual path will point to.
If your ModProject has the exact same name as GameProject, then plugin should be in the GameProject/Plugins/ folder.
Packaged file structure will look like this :
Engine
GameProject/
- Binaries
- Content/Paks/GameProject-Windows.pak
- Plugins/
- Mod1.upluginmanifest
- Mod1/
- Mod1.uplugin
- Content/Paks/Windows/Mod1GameProject-Windows.pak
In this case, the File
field can be shortened to ../../Plugins/Mod1/Mod1.uplugin
.
If however the ModProject has a different name, then you must create additional folders to match the paths of assets paked into the plugin PAK.
Packaged file structure must look like this :
Engine
GameProject/
- Binaries
- Content/Paks/GameProject-Windows.pak
- Plugins/
- Mod1.upluginmanifest
ModProject/
- Plugins/
- Mod1/
- Mod1.uplugin
- Content/Paks/Windows/Mod1GameProject-Windows.pak
"File": "../../../ModProject/Plugins/Mod1/Mod1.uplugin"
2. Mods as mods
As mentioned earlier, this approach does not require to manually create a .upluginmanifest file, however it comes with other constraints.
The PluginManager loads mods in GameProject/Mods folder so your packaged file structure must look like this :
Engine
GameProject/
- Binaries
- Content/Paks/GameProject-Windows.pak
- Mods/
- Mod1/
- Mod1.uplugin
- Content/Paks/Windows/Mod1GameProject-Windows.pak
PluginManager will detect the .uplugin, and automatically mount the pak, and will automatically create the following virtual path
/Mod1
→ ../../../GameProject/Mods/Mod1/Content
As seen earlier, when packaging our mod as a plugin we ended up with a PAK with the following contents :
Display: Mount point ../../../ModProject/Plugins/Mod1/
Display: "AssetRegistry.bin"
Display: "Content/BP_ModDerived.uasset"
Display: "Content/BP_ModDerived.uexp"
Display: "Content/M_Mat.uasset"
Display: "Content/M_Mat.uexp"
So there is clearly a mismatch as the PAK contains stuff in ModProject/Plugins/Mod1
while the plugin manager creates a path towards GameProject/Mods/Mod1
.
Luckily, fixing this is pretty straightforward :
-
First, the mod project must be exact same name as the game project.
-
Second, Plugins must be Mods instead. Fortunately, the two seem to be interchangeable. Editor and cooker seem to support mods just the same. So after creating your content plugin via editor UI, all you have to do is hop into the folders, create a Mods folder, and move the plugin from Plugins folder into the Mods folder, then reload editor. Nothing should have changed, but now after packaging the PAK will have its contents in
ModProject/Mods/Mod1
instead.
After packaging, simply drop the packaged Mods/ folder directly into the packaged GameProject/ folder and it should work just fine.
As a final note, all of this only works at game launch. As stated in the primer above, PluginManager will only look for plugins and PAKs on startup.
For any more dynamic solution, a custom C++ implementation will most likely be required.
But I figured it was a good idea to show what is possible to do with builtin tools already, and where problems can easily arise - ie. virtual path vs. paked path issues for the most part. Most of these traps would likely be present when dealing with dynamic pak loading as well.
If you are struggling with similar issues as I did, here are some debugging methods I used, that will dump info out of shipping builds (no logs) :
#include "Interfaces/IPluginManager.h"
#include "IPlatformFilePak.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "AssetRegistry/AssetRegistryHelpers.h"
void DebugPlugins()
{
TArray<FString> Output;
auto& PluginManager = IPluginManager::Get();
auto AllPlugins = PluginManager.GetDiscoveredPlugins();
for (const auto& Plugin : AllPlugins)
{
Output.Emplace(FString::Printf(TEXT("plugin:%s, enabled:%i, hasContent:%i, mount:%s, explicitlyLoaded:%i"), *Plugin->GetName(), Plugin->IsEnabled(), Plugin->CanContainContent(), *Plugin->GetMountedAssetPath(), Plugin->GetDescriptor().bExplicitlyLoaded));
}
FFileHelper::SaveStringArrayToFile(Output, *FPaths::Combine(FPaths::ProjectDir(), TEXT("plugins.log")));
}
void DebugPaks()
{
TArray<FString> Output;
auto PakPlatform = (FPakPlatformFile*)FPlatformFileManager::Get().FindPlatformFile(TEXT("PakFile"));
if (PakPlatform != nullptr)
{
TArray<FString> Paks;
PakPlatform->GetMountedPakFilenames(Paks);
for (const auto& Pak : Paks)
{
Output.Emplace(FString::Printf(TEXT("pak: %s"), *Pak));
}
}
else
{
Output.Emplace("PakPlatformFile = nullptr");
}
FFileHelper::SaveStringArrayToFile(Output, *FPaths::Combine(FPaths::ProjectDir(), TEXT("paks.log")));
}
void DebugVirtualPaths()
{
TArray<FString> Output;
TArray<FString> RootPaths;
FPackageName::QueryRootContentPaths(RootPaths);
for (const auto& RootPath : RootPaths)
{
FString ContentPath;
FPackageName::TryConvertLongPackageNameToFilename(RootPath, ContentPath);
Output.Emplace(FString::Printf(TEXT("RootPath: %s --> %s"), *RootPath, *ContentPath));
}
FFileHelper::SaveStringArrayToFile(Output, *FPaths::Combine(FPaths::ProjectDir(), TEXT("paths.log")));
}
void DumpAssets()
{
TArray<FString> Output;
auto AR = IAssetRegistry::Get();
auto DumpAssetsInPath = [&AR, &Output](const FString& Path) {
Output.Emplace(FString::Printf(TEXT("--- %s"), *Path));
TArray<FAssetData> Assets;
AR->GetAssetsByPath(*Path, Assets, true);
for (const auto& Asset : Assets)
{
Output.Emplace(FString::Printf(TEXT("Asset: %s"), *Asset.GetFullName()));
}
};
DumpAssetsInPath("/Game");
DumpAssetsInPath("/Mod1");
FFileHelper::SaveStringArrayToFile(Output, *FPaths::Combine(FPaths::ProjectDir(), TEXT("assets.log")));
}
void DebugChildBlueprints()
{
TArray<FString> Output;
auto AR = IAssetRegistry::Get();
TArray<FAssetData> Assets;
FARFilter Filter;
Filter.TagsAndValues.Add("ParentClass", FString("/Script/Engine.BlueprintGeneratedClass'/Game/BP_Base.BP_Base_C'"));
AR->GetAssets(Filter, Assets);
for (const auto& Asset : Assets)
{
Output.Emplace(FString::Printf(TEXT("Asset: %s"), *Asset.GetFullName()));
}
FFileHelper::SaveStringArrayToFile(Output, *FPaths::Combine(FPaths::ProjectDir(), TEXT("childbp.log")));
}
void DebugStaticLoad()
{
TArray<FString> Output;
auto StaticLoad = [&Output](const FString& Path) {
UObject* Obj = StaticLoadObject(UObject::StaticClass(), nullptr, *Path);
Output.Emplace(FString::Printf(TEXT("StaticLoad('%s'): %s"), *Path, Obj ? *Obj->GetFullName() : TEXT("NULL")));
};
StaticLoad("/Game/BP_GameDerived.BP_GameDerived_C");
StaticLoad("/Game/Mod1/BP_ModDerived.BP_ModDerived_C");
StaticLoad("/Mod1/BP_ModDerived.BP_ModDerived_C");
FFileHelper::SaveStringArrayToFile(Output, *FPaths::Combine(FPaths::ProjectDir(), TEXT("staticload.log")));
}