16 min read

The Journey Developing the Plugin System in Collapse Launcher - Part 1

A journey on developing the plugin solution allowing Collapse to be a universal game launcher solution, hopping through .NET unending limitations.
The Journey Developing the Plugin System in Collapse Launcher - Part 1

Introduction

It's been nearly 3 years since development started on the Collapse Launcher project. The primary goals of it was to enable gamers to access and launch titles published by one of the most well-known gacha game developer, miHoYo (HoYoverse) into a single, unified launcher experience.

Before HoYoPlay was a thing, it was a big hassle when you had to download a new title, especially with one of their older ones, Honkai Impact 3rd. The game was separated into different clients called "regions" (or "server" if you prefer) and, you guessed it, each region has THEIR OWN LAUNCHER. It was a nightmare for those who have to play on multiple different regions simultaneously. It doesn't help that, back then, the launcher was slow to launch, clunky and error-prone.

We took some inspiration from an existing third-party launcher for Honkai Impact 3rd called "BetterHi3Launcher", made by a Honkai Impact 3rd Official Discord moderator, BuIlDaLiBlE. Though the launcher is specifically purposed for bringing support for Honkai Impact 3rd, it served that purpose very well and fixed a lot of the gripes introduced with the standard game launcher.

However, one day, a thought crossed my mind...

"What if I make a launcher for the entire title?"


And the rest is history. Nowadays, Collapse Launcher supports multiple miHoYo (HoYoverse) game titles, including the very well-known Genshin Impact, Honkai: Star Rail, Honkai Impact 3rd, and Zenless Zone Zero.

Nowadays, I don't work alone. We have a team that helps maintain the project while developing new features and fixing bugs people have. One day at a core developers meeting, one of our fellows, Cryotechnic, said:

"Can we add support for other games from different game developers? How hard would that be?"

To which I replied:

"I don't think it's necessary to bring support for other games from different game developers as we are just focused on miHoYo (HoYoverse) titles."

This was a common joke in every weekly core developers meeting as the main goal of this project was just to bring support for miHoYo games. It was already challenging enough to support those titles, so why make that even harder by supporting other games?


And then...I changed my mind and decided to go ahead implementing the idea to add a plugin system into Collapse. Yes, I did end up breaking my promise but there's a few reasons as to why I did.

You see, gacha games have become much more popular, even more that we'd have thought, in recent years. But, as popular as those games are, unfortunately, most of them have a sluggish and imperfect (often barely functional or very invasive) launcher that often causes headache to its playerbase.

There are lots of gacha games other than miHoYo (HoYoverse) titles which garnered a lot of attention, one of the most well-known ones being Wuthering Waves, and we received a lot of requests from our users in hope that Collapse would eventually support the title as well.

Collapse Launcher has a lot of spaghetti, legacy code, some was copied from my old and long-dead project, Hi3Mirror/IronProxy (which is a game asset bundle API mirror for Honkai Impact 3rd) and Hi3Helper (A tool to fix Honkai Impact 3rd block ".wmv" files, Audio Assets Patcher and Cache Updater*). This code is mainly tied to the old, legacy official launcher and only works on most of Unity Engine-based Games (which are all of miHoYo titles since they all use a fork of Unity). But, after many weeks of rewriting, removing and changing the codebase, we've finally arrived at a point that the code is ready to be expanded more and is modular enough by today's standards.

In this article, we'll be diving deep into how the plugin system was developed and how we solved some challenging issues in order to make the system work. Unfortunately, contrary to popular demand, we are not going to start from creating a plugin for Wuthering Waves. Instead, we'll be including a game that isn't really talked about, but is simple to implement. That game title was Heaven Burns Red by VISUAL ARTS/Key.


Chapter 1: The Beginning

Where to start?

As I mentioned earlier, most of Collapse Launcher's code was widely tied to only work on miHoYo (HoYoverse) titles as Collapse mimmicks how the official launcher behaves. This ranged from not only how the launcher starts the game, but also including porting/re-implementing the launcher mechanism, including the (new) Sophon Downloading System. But we aren't going to talk about that today!

The first thought that came across my mind was:

"How can we make the plugin to be loaded, add the features and communicate alongside the main launcher process?"


Chapter 2: Making a Decision

Planning Implementation Methods (this won't be hard, will it?)

Well, as far as the loading process of the plugin goes, there are few options available on how to make it work. There are some advantages/disadvantages in each of the options though:

Option 1: Use .NET Runtime-based Reflection

This is the simplest option you can choose while implementing a plugin system to your .NET application. In .NET, there's a feature-set called "Reflection" where you can dynamically communicate with parts of the code you don't own and use their metadata, types, functions and such without knowing the part of the code is already there.

To make it easier, I asked ChatGPT to explain with a "daily-life" analogy of how the Reflection in .NET work:

ChatGPT said:

Imagine your program is a person. Normally, people just go about their day: eating, talking, walking — they know how to do those things without thinking about their internal structure.

But what if the person could pick up a magical mirror that not only shows their reflection — it also tells them:What clothes they’re wearing (fields/properties)What actions they’re capable of (functions)What roles they play (interfaces)Whether they have special traits (attributes, like being allergic to nuts)

That magical mirror is Reflection.

Basically, Reflection is a feature-set where you can predict what things might happen and how the outcome of a thing would look like without worrying to remember things all the time (statically).

While the terminology of the feature seems to be quite interesting, this came at a price.

This method is often slow, quite janky and produces a really big compiled library. We are not talking 10-20 MiB in-size, but often well over 200 MiB! Imagine shipping a single plugin when you realize that the plugin alone is 200 MiB in size and you have to download the entire library, which bundles some code that may or may not be used?!

The main reason of this is due to the entire runtime requiring to be shipped alongside the main library since most of the unused code isn't trimmed and is sometimes used dynamically by the Reflection feature.

The second reason as to why it's slow is because, under the hood, the runtime needs to perform numerous checks and lookups every time you perform an operation in the code as the runtime needs to predict where the metadata or piece of code is located outside of the main application.

You can still compile your plugin without the runtime included (but with Reflection enabled), but, the .dll won't be able to get loaded by the Collapse Launcher main process since it was compiled with nearly no reflection features enabled (thanks to the Full Trim mode enabled and, later, compiled under NativeAOT). Even if it was compiled with Reflection enabled, you have to install a different runtime for the plugin in order to make it work. If you're not an IT-thinker-er, this will be a very lengthy and complex process.

If you need more explanations about .NET Reflection and how things work, give a read to the articles below:

With that consideration in mind, let's move on to the next option...

Option 2: Use full C/PInvoke style

In this day and age, using C/PInvoke is a common practice, especially if you have an application which is written in C, C++, Rust, Go or any language that's statically-compiled into machine code. This is also a common way for the developer to communicate or use a part of the code from a shared, unmanaged library.

This was my first to-go option for starting on plugin development. But here's the catch:

  • C/PInvoke requires fully static function.
  • It's limited to struct-based data only. Meaning, if you need to perform some tasks where you need to pass complex parameters, you can only rely on basic, primitive data only.
  • Managing cross-version features is quite tricky.

The concern about the first issue is that, Collapse Launcher's codebase is heavily dependent on class-based data (due to C# being object-oriented). I also wanted to make the code reusable by using inheritance, so making it easier to wrap between different kinds of types. Also by its static function nature, this means that you need to define each function to be exposed by defining it explicitly using UnmanagedCallersOnly with Cdecl call convention. Using UnmanagedCallersOnly on .NET is also quite challenging since it requires you to manually marshal unmanaged data into managed data that .NET can recognize (let's say, for example, you wanted to convert/marshalling .NET string to unmanaged wchar_t/char* or vice versa). Yes, PInvoking on .NET is supported alongside marshalling by using MarshalAs() and LibraryImport. But that's for calling functions from .NET to an unmanaged library, not the other way around.

Furthermore, my concern on the second point is that, .NET is OOP (object oriented programming) framework. This means that you need to implement each of the objects/classes and convert them into much simpler struct-based data. it took some time for us to decide what data is going passed, and how we'd pass it to unmanaged functions. Wouldn't it be much simpler if you could call the unmanaged function to get the interface/object and call the function inside of that interface/object directly rather than PInvoking different static unmanaged functions one-by-one?

The last concern (the third point) is what I was concerned about the most. This plugin implementation (both from Collapse and the actual plugin itself) will be continuously developed by adding some features over its lifespan. Managing and deciding which parts of the functions to call on depending on the plugin version is not only tricky, it's also very complex. This opens some incompatibility issues where a function might behave differently on the next version when you expect the code to do the same thing as previous versions. This plugin (Heaven Burns Red in this case) is going to be developed by many developers without any awareness from our end if their plugin may work or not. Some developers have a different approach on developing it, some might follow our practices, who knows?

We really can't take anything for granted because the second we do, someone will come up with something different and break the implementation that we did.

To give you an idea about how the call flows might look, here's a (not-so-very-complex) diagram:

A diagram of call flows using C/PInvoke method. This image is distributed under Creative Commons BY-SA 4.0 License.

You might look at this and say "What am I even looking at, this is so confusing!" Don't worry, we came prepared! Here's a short summary of the above:

  • Each of the operations from the main application requires at least 2-3 steps before approaching the actual plugin code.
  • In order to convert the return data into .NET managed data, you're required to marshal it manually. There's no ABI (Application Binary Interface) as a middleware to marshal everything for you automatically. You MUST call the Marshaller in each of your .NET Managed functions.
  • To get the exact value of which data it represents (such as: PresetConfig, GameVersionManager, etc.), you need to at least pass the reference/pointer of the data that you grabbed from the constructor back to the plugin exports. If you, let's say, try to get a value from the GetZoneName() function, you need to pass the pointer of the PresetConfigData as the argument into the plugin export.
  • Using a manual marshaller might cause an unexpected behaviour and confusion as other plugin developer might choose the wrong marshaller that they shouldn't use, causing some unexpected data mishandling or even corruption and causing the main application to crash (also because the lack of ABI [Application Binary Interface] that is used as a barrier/standard of how the data should be marshalled.)
  • You need to manually free the memory of each of the returned data to avoid memory leaks.

While thinking which methods are more safer and future-proof, I honestly wanted to use this C/PInvoke option, since it's broadly common for managing unmanaged code. That is, until I found another approach that might solve all the problems of previous options. I decided to go for a hybrid implementation. Going more "experimental" I should say...

Option 3: Use hybrid C/Win32 + COM Interface Invocation

COM (Component Object Model) is a feature on Windows that enables cross-communication between libraries/applications. It's a common standard for any application that leverages plugin loading due to its easy-to-manage versioning. Also, some trivia: all of the plugins on Microsoft Office's applications are COM-based.

In .NET itself, COM is seamlessly integrated from the start since the framework's initial release (.NET Framework 1.x). However, it was heavily dependent on Reflection which is a disadvantage as we are targeting NativeAOT compilation. This is no longer an issue with .NET 8 and above since it has source-generated COM integration by using both [GeneratedComInterface] and [GeneratedComClass] attributes, making it Trim-mode and NativeAOT friendly😄.

Under the hood, COM is working on the binary-level and is commonly integrated with ABI (Application Binary Interface, not to be confused with API, Application Programming Interface), which is used to determine which functions are going to be used based on its function address during the VirtualTable lookup.

This is a big deal for managing versioning across different plugin as the location of each of the functions from different version of the code are fixed, meaning that the same function can still be interop'ed across different version.

Here's another diagram of how the Virtual Table looks like when inheriting functions for different versions of an interface (this one's much simpler to understand):

A diagram of Virtual Table stack under COM Interface. This image is distributed under Creative Commons BY-SA 4.0 License.

In this diagram, we can see how the Virtual Table functions on both IPluginPresetConfig and IPluginPresetConfig2 interfaces. As you can see, the IPluginPresetConfig2 interface derived some functions from IPluginPresetConfig. The derived functions are still the same with all of its address offsets still intact. The address of the new functions from IPluginPresetConfig2 are placed next to the offset of the last function address of IPluginPresetConfig, which is ABI_GetGameVersionManager(). This is why Interface is often called "contract", because it ensures that certain sets of previous or existing implementation MUST be the same in regards of how the next interpretation of an instance is going to be.

But then, how does the main application (Collapse Launcher) figure out which COM Interface type of an instance is being retrieved? This is where the "Interface Query" comes into play.

In COM, all the interfaces are represented by their own unique RFIID (basically a GUID) and is immutable, meaning that it can never be changed and is always consistent. A Query Table pointer (not to be confused with the previous Virtual Table) is returned by a static, unmanaged function in order to pass it to QueryInterface function. The purpose of a Query Table is to bring information about what type of interface of an instance can be used and also bringing its Virtual Table per type of interface.

In .NET, A Query Table is represented by a struct type called ComWrappers.ComInterfaceEntry and consists of two fields, including:

  • IID (aka. RFIID) with type of Guid
  • Vtable (aka. Virtual Table) with type of nint or IntPtr

Here's what the struct look like in the code:

public abstract class ComWrappers
{
    public struct ComInterfaceEntry
    {
        public Guid IID;
        public nint Vtable;
    }
}

An array of ComWrappers.ComInterfaceEntry struct is allocated to a static field in a class (to avoid GC) called Wrappers. The array is allocated with the size of how much Interface is defined/implemented in that instance, then assign the RFIID (aka. IID) and the pointer to the interface's Virtual Table. To give you an idea, here's what the code doing that looks like:

static PluginPresetConfigWrappers()
{
    ExInterfaceDefinitionsLen = 2; // The instance has 2 COM Interface definition/implementation

    // Allocate the array of ComInterfaceEntry with the size of 2
    ComInterfaceEntry* entries = 
		(ComInterfaceEntry*)RuntimeHelpers.AllocateTypeAssociatedMemory(
			typeof(PluginPresetConfigWrappers),
			sizeof(ComInterfaceEntry) * ExInterfaceDefinitionsLen);

    // IPluginPresetConfig2
    entries[0].IID = new Guid(ComInterfaceId.ExPluginPresetConfig2);
    entries[0].Vtable = (nint)ABI_VTables.IPluginPresetConfig2;

    // IPluginPresetConfig
    entries[0].IID = new Guid(ComInterfaceId.ExPluginPresetConfig);
    entries[0].Vtable = (nint)ABI_VTables.IPluginPresetConfig;
    
    // Assign the pointer to the static field.
    InterfaceDefinitions = entries;
}

The InterfaceDefinitions in the wrapper class is called: PluginPresetConfigWrappers will be used later for Querying what kind of type of interface from that instance should be used (whether IPluginPresetConfig2 or IPluginPresetConfig).

This brings us back to our initial question:

How does the main application (Collapse Launcher) figure out which COM Interface type of an instance is being retrieved?

To give you a better explanation, look at these two samples:

Under Hi3Helper.Plugin.***.dll:

// An extension code to help converting the COM instance object into a pointer of ComInterfaceEntry
internal static unsafe class ComWrappersExtension<T>
    where T : ComWrappers, new()
{
    private static T? _cachedWrappers;

    internal static void* GetComInterfacePtrFromWrappers(object? obj,
														 CreateComInterfaceFlags flags = 
															CreateComInterfaceFlags.None)
    {
        if (obj == null)
        {
            return null;
        }

        _cachedWrappers ??= new T();
        return (void*)_cachedWrappers.GetOrCreateComInterfaceForObject(obj, flags);
    }
}

// Unmanaged Function for returning the COM instance into a pointer.
// This PluginPresetConfig derived IPluginPresetConfig2 COM interface.
private static IPluginPresetConfig2 _cachedPresetConfig = new PluginPresetConfig(); 

// This function will then be shown as an ``Export`` after the plugin is compiled.
[UnmanagedCallersOnly(EntryPoint = "GetPresetConfig", CallConvs = [typeof(CallConvCdecl)])]
public static unsafe void* GetPresetConfig() => ComWrappersExtension<PluginPresetConfigWrappers>
	.GetComInterfacePtrFromWrappers(_cachedPresetConfig)

The code above is responsible to pass a COM instance of IPluginPresetConfig2 to whatever main application needs it (in this case, Collapse Launcher). The instance is then converted using ComWrappersExtension<T>.GetComInterfacePtrFromWrappers() which returns a pointer. Then, an unmanaged function is called: GetPresetConfig() is used to pass the "already converted" pointer into the caller/main application.

After the pointer is retrieved, the main application must convert it back to an instance of COM interface, IPluginPresetConfig or IPluginPresetConfig2. In the snippet below, we show an example of the usage of the QueryInterface function.

Under CollapseLauncher.exe:

// This is a pointer of Hi3Helper.Plugin.***.dll 
//  after being loaded using NativeLibrary.Load()
private static nint _pluginHandle;

// This implements a dwarf delegate of the GetPresetConfig function.
private unsafe delegate void* GetPresetConfig();

public static unsafe (IPluginPresetConfig Instance, bool isV2) GetPresetConfigInstance()
{
    // Get the pointer to the unmanaged function: "GetPresetConfig" under Hi3Helper.Plugin.***.dll
    nint export_GetPresetConfig = 
		NativeLibrary.GetExport(_pluginHandle, "GetPresetConfig");

    // Convert the pointer of the export to the actual delegate callback
    GetPresetConfig callback = 
		Marshal.GetDelegateForFunctionPointer<GetPresetConfig>
			(export_GetPresetConfig);

    // Get the pointer from the callback
    void* comInstancePlugin = callback();

    // Allocate both Guid for IPluginPresetConfig2 and IPluginPresetConfig
    Guid v2IID = new Guid(ComInterfaceId.ExPluginPresetConfig2);
    Guid v1IID = new Guid(ComInterfaceId.ExPluginPresetConfig);

    // Try query the instance if it's IPluginPresetConfig2
    HResult resultIfV2 = (HResult)Marshal.QueryInterface(
							(nint)comInstancePlugin,
							in v2IID,
							out nint comPtr);

    // If it's OK (as it's able to query the instance as IPluginPresetConfig2, then return)
    if (resultIfV2 == HResult.OK)
    {
        IPluginPresetConfig2 instanceV2 = ComInterfaceMarshaller<IPluginPresetConfig2>
											.ConvertToManaged((void*)comPtr);
        return (instanceV2, true);
    }

    // Otherwise, fallback to try querying IPluginPresetConfig instead (also throw if it still fail)
    ((HResult)Marshal.QueryInterface(
						(nint)comInstancePlugin,
						in v1IID,
						out comPtr
						)).ThrowOnFailure();

    // If it's successful, return as IPluginPresetConfig.
    IPluginPresetConfig instanceV1 = ComInterfaceMarshaller<IPluginPresetConfig>
										.ConvertToManaged(
											(void*)comPtr);
    return (instanceV1, false);
}

As you can see, the QueryInterface() function is utilized to perform a query where it finds which COM interface is defined/implemented by looking at the RFIID (or IID/Guid). The function will perform a lookup into the pointer of the comInstancePlugin variable (which is actually a pointer to the ComInterfaceEntry struct) and finds a matching RFIID (or IID/Guid). Once found, it will output a new pointer to then be passed to ComInterfaceMarshaller<T>.ConvertToManaged() to convert it back to an actual instance of either IPluginPresetConfig2 or IPluginPresetConfig COM interface. Phew, that was a mouthful 😅.

In this way, we can predict which type of an instance is going to be used and determine the versioning of the plugin, thus knowing which feature can and cannot be used.

Another thing that this solution can do is use the built-in marshaller, which is useful to convert the data type back to .NET managed types. You can basically write nearly the same code and use the data that's passed to the argument without worrying if the argument is actually a pointer. That means that you can use the same data type for the function arguments from the main application where you call the plugin from and in the plugin itself!

So, instead of writing this on the plugin side:

using wchar_t = System.Char;

public class PresetConfig1 : IPluginPresetConfig
{
    public void SetGamePath(wchar_t* path)
    {
        // Convert the "path" argument as managed .NET string and do the thing.
        ReadOnlySpan<char> pathAsStr = MemoryMarshal.
			CreateReadOnlySpanFromNullTerminated(path);
        
		string str = pathAsStr.ToString();
        Console.WriteLine(str);
    }
}

You can write this:

public class PresetConfig1 : IPluginPresetConfig
{
    public void SetGamePath([MarshalAs(UnmanagedType.LPWStr)] string? path)
    {
        // The "path" argument is already converted to managed .NET string
        Console.WriteLine(path);
    }
}

Pretty neat, huh? This is all possible thanks to source-generated COM wrappers that shipped with .NET 8 and above. Under the hood, it generates bunch of ABI code automatically, which us used both as a bridge and a marshaller. These ABI functions are then referenced into the Virtual Table to marshal and pass the data between codes at the same time.

By describing some of the things that are happening under the hood, here's an approximate diagram of how this third option works:

A diagram of call flows using Hybrid C/Win32 + COM Interface method. This image is distributed under Creative Commons BY-SA 4.0 License.

Here's a summary of comparing the previous diagram in the second option (Full C/PInvoke style) with the one above:

  • Each of the operations on the main application only requires one step since all the functions are included inside of the COM Interface instance.
  • Instead of going to each of the Unmanaged Functions, unlike the second option (Full C/PInvoke style), the functions inside of the plugin are bridged by the ABI functions which are referenced in the "Function VTables (Virtual Tables)". The marshalling also happens under the ABI functions so you don't need to worry about unmanaged data conversion into .NET Managed types.
  • You don't need to pass an instance in order to get the data for each functions that it represents (such as: PresetConfig, GameVersionManager, etc.). What you need is just use the COM Instance and call the function inside of it. No need to pass the instance as argument!
  • Since the marshalling is already happening under the ABI function, the data formation is guaranteed and should be handled more correctly. All the marshalling process is standardized and consistent across different codebases.
  • You don't need to worry about memory leaks. All the data allocated to the memory is freed automatically once it's marshalled by the ABI functions.
  • No need to call the static functions one-by-one just to complete an operation. All the functions are already included inside of the COM interface.

With that being said, I decided to go with the third option to start the development of the plugin so, what can go wrong 😃?

In the next article, we are going to talk about how the plugin is being implemented and the struggle of making it later. But that's all for now!

See you in the next article~