Kostiantyn's Blog

rss icon

Writing an OBS plugin with .NET NativeAOT

Photo of OBS
OBS
Photo by César Abner Martínez Aguilar on Unsplash

.NET 7 release is really soon, but I cannot wait to try one of its newest additions. Usually, a .NET application consists of IL code that is compiled Just-In-Time (by a JIT compiler) when the application is launched. NativeAOT is a .NET feature that allows compiling any .NET project into native assembly code directly.

Why would one want to use NativeAOT? Many are probably already allergic to this phrase but it depends™. Some advantages are:

  • faster startup (by removing the "IL compilation" phase of the startup path)
  • being able to run on platforms that restrict code generation (JIT compiling is code generation)
  • fully statically analyzable (no code can be added after compilation)

Some disadvantages:

  • can not add code after startup (duh!)
  • slower compilation
  • has to be trimmed (can be an advantage too!)

But! For me, the advantage that I am going to use here (and the one that I conveniently left out from my points before), is that I can easily interoperate with other native applications. Since the output of a NativeAOT compilation process is a native library (or executable), this library can be loaded by others seamlessly. For example, the library can be loaded as an OBS plugin.

TLDR: We wrote an OBS plugin in C# by using .NET 7 NativeAOT. The code for the plugin is in the DotnetObsPluginWithNativeAOT repository

OBS plugin model

How does the OBS plugin model work? By looking at the official documentation, we can see that the main point is to provide OBS with a native shared library with several defined exported functions.

In the official example, we see a call to the OBS_DECLARE_MODULE() C macro and an implementation of a bool obs_module_load(void) function that will be called by the OBS runtime. If the obs_module_load function is totally understandable - a hook for OBS to call on startup, the macro looks like magic. Let's go deeper!

We can do that in the OBS github repository. We can find the interesting macro inside the obs-module.h file.

c
...
/** Required: Declares a libobs module. */
#define OBS_DECLARE_MODULE()                                                  \
	static obs_module_t *obs_module_pointer;                              \
	MODULE_EXPORT void obs_module_set_pointer(obs_module_t *module);      \
	void obs_module_set_pointer(obs_module_t *module)                     \
	{                                                                     \
		obs_module_pointer = module;                                  \
	}                                                                     \
	obs_module_t *obs_current_module(void) { return obs_module_pointer; } \
	MODULE_EXPORT uint32_t obs_module_ver(void);                          \
	uint32_t obs_module_ver(void) { return LIBOBS_API_VER; }
...

From this, we can make a conclusion, that the OBS plugin model requires two additional functions - void obs_module_set_pointer(obs_module_t *module) and uint32_t obs_module_ver(void).

OK, to conclude, the requirements for an OBS plugin is three exported functions in a dynamic library - bool obs_module_load(void), void obs_module_set_pointer(obs_module_t *module) and uint32_t obs_module_ver(void). Sounds reasonable, we can totally do that in C#.

Implementing an OBS plugin

Recently, I have published OBS .NET interop bindings in NetObsBindings library. I have described the full process in the blog post here. These are helpful because we want to interact with OBS to do something useful. We will use this library throughout this blog post.

Before doing the work, let's decide on how we will test that OBS actually loads our plugin. Thankfully, OBS has a helpful blog function that logs an ASCII string to the OBS log. It allows us to do two things: verify that our functions are called and that we can interact with OBS.

c#
private static unsafe void Log(string text)
{
    var asciiBytes = Encoding.UTF8.GetBytes(text);
    fixed (byte* logMessagePtr = asciiBytes)
    {
        ObsUtilBase.blogva(ObsUtilBase.LOG_INFO, (sbyte*) logMessagePtr, null);   
    }
}

OK, let's implement the functions that we discovered.

Firstly, let's implement the pointer saving function:

c#
public static nint ObsModulePointer { get; set; }

[UnmanagedCallersOnly(EntryPoint = "obs_module_set_pointer", CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })]
public static void SetPointer(nint obsModulePointer)
{
    Log("[blog] Pointer Saved!");
    ObsModulePointer = obsModulePointer;
}

We add the UnmanagedCallersOnly attribute because, by convention, the functions marked with this attribute will be 'exported', meaning visible to other libraries loading our code. We do want OBS to see this function so that it will be able to call it. You may have also noticed that we use the exact name of the function in the UnmanagedCallersOnly.EntryPoint property. That is very important since OBS will look for the function with this exact name.

Then, we need to give OBS the version of APIs we are using. This version is baked into the NetObsBindings library in the Obs.Version property - from the OBS versions that these bindings were generated.

c#
[UnmanagedCallersOnly(EntryPoint = "obs_module_ver", CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })]
public static uint GetVersion()
{
    Log("[blog] Returned version!");
    
    var major = (uint) Obs.Version.Major;
    var minor = (uint) Obs.Version.Minor;
    var patch = (uint) Obs.Version.Build;
    var version = (major << 24) | (minor << 16) | patch;
    return version;
}

And, finally, let's add the function that is called when our module is loaded:

c#
[UnmanagedCallersOnly(EntryPoint = "obs_module_load", CallConvs = new[] {typeof(System.Runtime.CompilerServices.CallConvCdecl)})]
public static bool ModuleLoad()
{
    Log("[blog] Loaded!");
    return true;
}

Cool! How do we build this stuff?

Firstly, we need to add <PublishAot>true</PublishAot> to the PropertyGroup section of our project file. Then it's actually pretty easy, we need to pass the correct command line arguments to dotnet publish. We want a native shared library for windows (I am on windows, but feel free to change to the linux one if needed):

sh
dotnet publish -c Release -o publish -r win-x64 /p:NativeLib=Shared /p:SelfContained=true

Deploying to OBS

To deploy our stuff, we will grab the files from the 'publish' directory and put them into the OBS plugins directory (for me, it's C:\Program Files\obs-studio\obs-plugins\64bit).

Now we can check if the stuff we have done actually works. To do that, we will open the OBS logs.

OBS logs open button

And the logs are there. Nice!

OBS logs by the plugin


Tags: csharp interop nativeaot obs