A .NET library inside the game client
This is the post I promised at the end of the last one: the MU Online client doesn't implement the network protocol in C++ anymore. It calls into a .NET library instead - the same one the server uses - and that library is loaded straight into the client's own process. No service, no second process, no IPC, no .NET runtime installed on the player's machine. Just a DLL sitting next to main.exe, exporting plain C functions.
I've written a fair amount of interop code over the years, but I have never seen this particular combination anywhere else, and until recently it wasn't even possible. So it deserves its own post.
The problem: two implementations of one protocol
OpenMU has a network library, MUnique.OpenMU.Network. It knows the packet structures, it knows the two encryption schemes (SimpleModulus and the Xor stuff I wrote about years ago), and it's the part of the project which is best covered by tests, because getting a byte wrong there means nothing works at all.
The client had its own C++ implementation of exactly the same thing. So every protocol change meant doing the work twice, in two languages, with two chances to get the bit shifting wrong. And since I had just started extending the protocol, "twice" was going to be a lot of work.
The obvious idea - let both ends use the same library - has an obvious problem: one end is a 20-year-old C++ program and the other is .NET.
Why this only works now
The traditional answer is to host the runtime: link against nethost/hostfxr, start CoreCLR inside your process, load an assembly, get a function pointer, and ship a runtime with your game. That works, and it's a lot of moving parts to put into a game client which players install by unpacking a zip.
Native AOT changes the shape of the problem. The library is compiled ahead of time into native code, it's self-contained, there is no JIT and no runtime to install, and - the important part for this use case - it can export ordinary C entry points that any native program can resolve with GetProcAddress or dlsym. From the client's point of view it is simply a native DLL. It doesn't know or care that the code inside was written in C#.
And here's the detail which made this a "newest .NET" story rather than something I could have done in 2023: the client is 32-bit. Native AOT on Windows supported x64 and Arm64 in .NET 8 - x86 was added in .NET 9. A 20-year-old game client is exactly the kind of program that is still x86, and before that table gained its third entry, this whole approach was off the table for it. The library targets .NET 10 today, and CMake picks the runtime identifier
win-x86,win-x64orlinux-x64- from the build it's producing.
How it fits together
Three pieces: the exports, the loading, and the code generation which writes most of it.
The exports
The managed side is a static class whose methods carry [UnmanagedCallersOnly]. That attribute makes a method callable directly from native code, with a chosen export name, and no marshalling magic in between - you get what a C function gets. Connecting looks like this:
[UnmanagedCallersOnly(EntryPoint = "ConnectionManager_Connect")]
public static int Connect(
IntPtr hostPtr,
int port,
byte isEncrypted,
delegate* unmanaged<int, int, byte*, void> onPacketReceived,
delegate* unmanaged<int, void> onDisconnected)
Two things to notice. First, the return value is an int handle, not a pointer to anything managed - the C++ side never holds a reference to a .NET object, it holds a number, and the library keeps a dictionary of connections behind it. Second, the last two parameters are function pointers back into C++. That's how received packets and disconnects get delivered: the managed side reads from the socket, decrypts, and calls the client's static handler with the handle, a length and a pointer to the bytes.
Around that there are ConnectionManager_Send, ConnectionManager_BeginReceive, ConnectionManager_Disconnect - and then one export per packet type. Currently that's over 200 exported entry points.
The loading
No hosting API, no coreclr_initialize. The client loads the library the way it would load any other DLL, on first use:
inline HINSTANCE get_munique_client_library_handle()
{
static const HINSTANCE handle = LoadLibrary(L"MUnique.Client.Library.dll");
return handle;
}
On Linux it's dlopen of MUnique.Client.Library.so, resolved through /proc/self/exe so it's found regardless of the working directory. Each exported function is then resolved once into an inline function pointer, and the call sites just call it.
The construct-on-first-use pattern above isn't decoration, by the way. The function pointers are inline globals in other translation units, and they resolve their symbols during their own dynamic initialization - a plain global handle would have been a static initialization order fiasco waiting for a full moon.
The code generation
I was not going to write 200 exports by hand, and I was not going to write their C++ counterparts by hand either.
The packet definitions live in XML - the same XML the server generates its packet structs from, which I wrote about in Generating message structs by data. The client library pulls them in as a NuGet package, so it's pinned to a version of the definitions instead of copy-pasted from somewhere.
From that XML, five XSL transformations produce:
- the C# methods with their
[UnmanagedCallersOnly]attributes, - C++ headers and sources with a nice class-based API for the client,
- C++ binding headers with the
typedefs and symbol lookups, - and the enums, for both languages.
The generated C# for the client-to-server direction alone is about 6.700 lines. On the C++ side, sending a chat message ends up as:
void PacketFunctions_ClientToServer::SendPublicChatMessage(
const wchar_t* character, const wchar_t* message)
{
dotnet_SendPublicChatMessage(this->GetHandle(), character, message);
}
which is exactly as boring as it should be. The client code calling it has no idea that the next stop is a garbage-collected language.
What bit us
wchar_t is not wchar_t. On Windows it's 2 bytes and holds UTF-16, on Linux it's 4 bytes and holds UTF-32, while Marshal.PtrToStringAuto always decodes UTF-16. The result of that mismatch, when the client was first brought up on Linux, was that the server address turned into nonsense, connect() hung on the main thread, and the game looked like it was frozen on a black screen. The library now decodes by the platform's real wchar_t width.
Cross-compilation has a hard edge. A Linux dotnet cannot AOT-publish a Windows library. The build has to distinguish between the build-time C# tools (which run on any host and generate C++ source) and the AOT publish step (which can't cross the OS boundary). We learned that by having the CI fail on missing generated headers.
MSBuild will happily redo everything. The XSL transformation target had no Inputs/Outputs at first, so it re-ran and rewrote every generated file on every single build, which in turn made CMake think the world had changed. Incremental builds are a feature you have to ask for.
And the usual Native AOT rules apply: no runtime code generation, trimming is part of the deal, and every managed exception has to be caught before it reaches the C++ frame above it. The exported methods are all wrapped accordingly.
Was it worth it?
Yes, and not by a small margin. There is exactly one implementation of the protocol now. When I add a packet, I add it to the XML, rebuild, and both sides have it - the server because it always did, the client because its bindings are generated from the same file. The encryption is maintained once. The tests which cover the server's network code cover the client's network code, because it is the same code.
The strange part is how unexciting it feels from the inside. A C++ game from 2005 calls a function pointer, and on the other side of that pointer sits modern C# with pipelines and spans, doing the actual socket work. The two halves are about twenty years apart and they get along fine.