How to ILMerge Unmanaged DLLs in .NET Using BoxedApp SDK (BxILMerge)

ILMerge is a well-known tool for combining multiple managed .NET assemblies into one. However, it cannot merge unmanaged DLLs because it works at the IL level. In this article, you’ll learn how to overcome this limitation using BoxedApp SDK and Mono.Cecil to build BxILMerge — a tool that merges any type of files, including native DLLs.

Virtualization allows unmanaged DLLs to be embedded directly inside the final assembly. When the application runs, BoxedApp SDK serves them from memory instead of the file system.

ILMerge Limitations

ILMerge can only handle managed assemblies. It merges the IL code, metadata, and resources of secondary DLLs into the main one. Unfortunately, unmanaged libraries (like native C++ DLLs) cannot be merged, since ILMerge operates purely at the IL level. This article shows how to overcome this by combining virtualization with Mono.Cecil.

Idea: Virtualization + Mono.Cecil

The concept is simple: use BoxedApp SDK to create virtual files for unmanaged libraries and Mono.Cecil to inject initialization logic into the merged assembly. The code that handles virtualization resides in a helper library called BxIlMerge.Api.dll.

Embedding Files

Each file to be embedded is added as an embedded resource in the primary assembly with a unique name pattern bx<guid><filename>. Here’s how to add unmanaged DLLs as embedded resources with Mono.Cecil:

using (AssemblyDefinition inputAssembly = AssemblyDefinition.ReadAssembly(
    inputAssemblyPath,
    new ReaderParameters() { SymbolReaderProvider = new PdbReaderProvider(), ReadSymbols = true }))
{
    var resources = inputAssembly.MainModule.Resources;
 
    foreach (string fileToEmbedPath in filesToEmbedPaths)
    {
        string embeddedResourceName;
        do
        {
            embeddedResourceName = string.Format("bx\\{0}\\{1}", Guid.NewGuid(), Path.GetFileName(fileToEmbedPath));
        } while (null != resources.FirstOrDefault(x => x.Name == embeddedResourceName));
 
        resources.Add(new EmbeddedResource(
            embeddedResourceName,
            ManifestResourceAttributes.Public,
            File.OpenRead(fileToEmbedPath)
        ));
    }
}

Virtualizing Files

The next step is to extract the embedded files and create virtual equivalents using BoxedAppSDK_CreateVirtualFile. This code belongs to BxIlMerge.Api.dll:

// Creates virtual files for each embedded resource
public static void CreateVirtualFiles(Assembly assembly)
{
    BoxedAppSDK.NativeMethods.BoxedAppSDK_Init();
 
    foreach (string embeddedResourceName in assembly.GetManifestResourceNames())
    {
        if (embeddedResourceName.StartsWith(@"bx\") &&
            embeddedResourceName.Length > @"bx\".Length + Guid.NewGuid().ToString().Length &&
            '\\' == embeddedResourceName[@"bx\".Length + Guid.NewGuid().ToString().Length])
        {
            string virtualFileName = embeddedResourceName.Substring(@"bx\".Length + Guid.NewGuid().ToString().Length + 1);
 
            using (SafeFileHandle virtualFileHandle = new SafeFileHandle(
                BoxedAppSDK.NativeMethods.BoxedAppSDK_CreateVirtualFile(
                    Path.Combine(Path.GetDirectoryName(assembly.Location), virtualFileName),
                    NativeMethods.EFileAccess.GenericWrite,
                    NativeMethods.EFileShare.Read,
                    IntPtr.Zero,
                    NativeMethods.ECreationDisposition.CreateAlways,
                    0,
                    IntPtr.Zero),
                true))
            {
                using (Stream virtualFileStream = new FileStream(virtualFileHandle, FileAccess.Write))
                using (Stream embeddedResourceStream = assembly.GetManifestResourceStream(embeddedResourceName))
                {
                    byte[] data = new byte[embeddedResourceStream.Length];
                    embeddedResourceStream.Read(data, 0, data.Length);
                    virtualFileStream.Write(data, 0, data.Length);
                }
            }
        }
    }
}

Resolving Dependencies

The merged assembly must also resolve BxIlMerge.Api.dll and BoxedAppSDK.Managed.dll at runtime. Both are embedded as resources and loaded dynamically through AppDomain.AssemblyResolve.

Module Initializer

The final piece is adding a module initializer that runs automatically when the assembly loads. It sets up the assembly resolver and invokes the method that virtualizes embedded files. This ensures that all unmanaged DLLs are ready before the main code runs.

Result

The result is a single executable that contains both managed and unmanaged DLLs. At runtime, BoxedApp SDK serves native libraries from memory without ever writing them to disk — a seamless way to overcome ILMerge’s original limitation.