Right off the heels of the previous post, let’s actually start hosting the .NET runtime and executing managed code in our C++ code base. This is the third in a series of posts on building a .NET 6.0-based C# scripting engine for a game engine. If you’ve not yet read the previous posts, you can start here.

Preparation

Managed Functions

Currently, we do not have any managed code written, so we will first begin by writing a public C++/CLI managed class that contains a static function which we can call from our C++-based Core project. For the purposes of this tutorial, I will not be going into detail on what C++/CLI syntax since the dialect does add an insignificant amount of features to the language. If you are interested, this article gives a good crash course into the dialect even though it is a bit old.

We will create a new class called EngineInterface and place it in the ScriptAPI namespace along with a simple function that prints out “Hello World!”.

// EngineInterface.hxx
#pragma once

namespace ScriptAPI
{
    // ref classes are classes in C#, value classes are structs in C#
    public ref class EngineInterface 
    {
      public:
          static void HelloWorld();
    };
}
// EngineInterface.cxx
#include "EngineInterface.hxx"

namespace ScriptAPI
{
    void EngineInterface::HelloWorld()
    {
        System::Console::WriteLine("Hello Managed World!");
    }
}

Since this class is a C++/CLI project, I decided to make it clear by having these files use the .hxx and .cxx file extensions for the header and source files respectively. However, this is all up to you, you can keep it as .h and .cpp too.

Native Functions

We will also want to call native functions from our managed code so let’s create a function on our Application class that also emits a Hello World message.

// Application.h
class DLL_API Application
{
  public:
    void Run();

    static void HelloWorld();
};
// Application.cpp
void Application::HelloWorld()
{
    std::cout << "Hello Native World!" << std::endl;
}

Notice how for both managed and native code that we wish to interact we are using static functions. This isn’t required as C++/CLI will allow us to pass C++ objects to C++/CLI code. However, we are using static functions to keep things simple for now.

Hosting .NET

Now we will start adding the code to our Application::Run() function to load and safely unload the .NET runtime. Most of the code you see here comes from SampleHost.cpp in this sample repository from Mike Rousos, principal engineer at Microsoft on the .NET team.

Loading .NET

Load CoreCLR.dll

The first step is to load coreclr.dll from our application so that we are able to use the functions contained within it to interface with the .NET runtime. To do this, we will add the following function declarations and data member definitions to the Application class.

// Application.h
...
// Relative include as this file will be included from other projects later
#include "../extern/dotnet/include/coreclrhost.h" 

namespace Core
{
    class DLL_API Application
    {
      ...
      private:
        void startScriptEngine();

        // References to CoreCLR key components
        HMODULE coreClr       = nullptr;
        void* hostHandle      = nullptr;
        unsigned int domainId = 0;
    }
}

Next, we will define startScriptEngine() and load the DLL.

// Application.cpp
#include <shlwapi.h>                // GetModuleFileNameA(), PathRemoveFileSpecA()

#pragma comment(lib, "shlwapi.lib") // Needed for <shlwapi.h>

...

void Application::startScriptEngine()
{
    // Get the current executable directory so that we can find the coreclr.dll to load
    std::string runtimePath(MAX_PATH, '\0');
    GetModuleFileNameA(nullptr, runtimePath.data(), MAX_PATH);
    PathRemoveFileSpecA(runtimePath.data());
    // Since PathRemoveFileSpecA() removes from data(), the size is not updated, so we must manually update it
    runtimePath.resize(std::strlen(runtimePath.data()));

    // Construct the CoreCLR path
    std::string coreClrPath(runtimePath); // Works
    coreClrPath += "\\coreclr.dll";

    // Load the CoreCLR DLL
    coreClr = LoadLibraryExA(coreClrPath.c_str(), nullptr, 0);
    if (!coreClr)
        throw std::runtime_error("Failed to load CoreCLR.");
}

Retrieving Hosting Functions

Once we have the DLL loaded, we need to grab functions from the DLL for managing the .NET Runtime’s lifecycle. We will add function pointer data members to store the functions and a template helper function to help us do this:

// Application.h
#include <stdexcept>

...

namespace Core
{
    class DLL_API Application
    {
      ...
      private:
        // Function Pointers to CoreCLR functions
        coreclr_initialize_ptr      initializeCoreClr = nullptr;
        coreclr_create_delegate_ptr createManagedDelegate = nullptr;
        coreclr_shutdown_ptr        shutdownCoreClr = nullptr;

        // Helper Functions
        template<typename FunctType>
        FunctType getCoreClrFuncPtr(const std::string& functionName)
        {
            auto fPtr = reinterpret_cast<FunctType>(GetProcAddress(coreClr, functionName.c_str()));
            if (!fPtr)
                throw std::runtime_error("Unable to get pointer to function.");
            return fPtr;
        }
    }
}

Now, we can use getCoreClrFuncPtr() to get the .NET hosting functions:

// Application.cpp
void Application::startScriptEngine()
{
    ...

    // Step 2: Get CoreCLR hosting functions
    initializeCoreClr     = getCoreClrFuncPtr<coreclr_initialize_ptr>("coreclr_initialize");
    createManagedDelegate = getCoreClrFuncPtr<coreclr_create_delegate_ptr>("coreclr_create_delegate");
    shutdownCoreClr       = getCoreClrFuncPtr<coreclr_shutdown_ptr>("coreclr_shutdown");
}

Construct Trusted Platform Assembly (TPA) List

For this next part, we need to construct a list of DLLs that tells the CLR which are the DLLs it can load at runtime and where to find them. Any DLLs not a part of this list will not be able to be loaded. This involves iterating through the DLLs that we have included and building the strings that contain this information.

First, we’ll construct the function that builds the string:

// Application.cpp
// We're omitting the function declaration in Application.h here but do remember to add it
std::string Application::buildTpaList(const std::string& directory)
{
    // Constants
    static const std::string SEARCH_PATH = directory + "\\*.dll";
    static constexpr char PATH_DELIMITER = ';';

    // Create a osstream object to compile the string
    std::ostringstream tpaList;

    // Search the current directory for the TPAs (.DLLs)
    WIN32_FIND_DATAA findData;
    HANDLE fileHandle = FindFirstFileA(SEARCH_PATH.c_str(), &findData);
    if (fileHandle != INVALID_HANDLE_VALUE)
    {
        do
        {
            // Append the assembly to the list
            tpaList << directory << '\\' << findData.cFileName << PATH_DELIMITER;
        } 
        while (FindNextFileA(fileHandle, &findData));
        FindClose(fileHandle);
    }

    return tpaList.str();
}

Then we’ll construct the arrays of C-strings that provide the key and values for this data so that we can pass it to the CLR when we initialize it later.

// Application.cpp
...
#include <array>

void Application::startScriptEngine()
{
    ...

    // Step 3: Construct AppDomain properties used when starting the runtime
    std::string tpaList = buildTpaList(runtimePath);

    // Define CoreCLR properties
    std::array propertyKeys =
    {
        "TRUSTED_PLATFORM_ASSEMBLIES",      // Trusted assemblies (like the GAC)
        "APP_PATHS",                        // Directories to probe for application assemblies
    };
    std::array propertyValues =
    {
        tpaList.c_str(),
        runtimePath.c_str()
    };
}

Load the .NET Runtime

Now all that’s left is to put everything together to load the runtime. We will need to use the initializeCoreClr() function pointer we retrieved earlier to launch the CLR. Don’t forget to call the startScriptEngine() in Run() too.

// Application.cpp
#include <sstream>

...

void Application::Run()
{
    startScriptEngine(); // Remember to call this function to start the CLR

    ...
}

void Application::startScriptEngine()
{        
    // Step 4: Start the CoreCLR runtime
    int result = initializeCoreClr
    (
        runtimePath.c_str(),     // AppDomain base path
        "SampleHost",            // AppDomain friendly name, this can be anything you want really
        propertyKeys.size(),     // Property count
        propertyKeys.data(),     // Property names
        propertyValues.data(),   // Property values
        &hostHandle,             // Host handle
        &domainId                // AppDomain ID
    );

    // Check if intiialization of CoreCLR failed
    if (result < 0)
    {
        std::ostringstream oss;
        oss << std::hex << std::setfill('0') << std::setw(8)
            << "Failed to initialize CoreCLR. Error 0x" << result << "\n";
        throw std::runtime_error(oss.str());
    }
}

Unloading .NET

Unloading the .NET runtime is much easier. All we have to do is to call the shutdownCoreClr() function pointer that we retrieved earlier. Do note that we do not need to unload the coreclr.dll. We will put this into a stopScriptEngine() function that we will call at the end of Run().

// Application.cpp
void Application::Run()
{
    ...

    stopScriptEngine();
}

void Application::stopScriptEngine()
{
    // Shutdown CoreCLR
    const int RESULT = shutdownCoreClr(hostHandle, domainId);
    if (RESULT < 0)
    {
        std::stringstream oss;
        oss << std::hex << std::setfill('0') << std::setw(8)
            << "Failed to shut down CoreCLR. Error 0x" << RESULT << "\n";
        throw std::runtime_error(oss.str());
    }
}

Executing Managed Functions

Now, that we got all of the boilerplate set up, let’s start calling some managed functions. To begin, we need to be able to grab pointers to these managed functions first. We can do this by writing a function template:

// Application.h
...

#include <sstream>
#include <iomanip>


namespace Core
{
    class DLL_API Application
    {
      ...
      private:
        // Helper Functions
        template<typename FunctionType>
        FunctionType GetFunctionPtr(const std::string_view& assemblyName, const std::string_view& typeName, const std::string_view& functionName)
        {
            FunctionType managedDelegate = nullptr;
            int result = createManagedDelegate
            (
                hostHandle,
                domainId,
                assemblyName.data(),
                typeName.data(),
                functionName.data(),
                reinterpret_cast<void**>(&managedDelegate)
            );

            // Check if it failed
            if (result < 0)
            {
                std::ostringstream oss;
                oss << std::hex << std::setfill('0') << std::setw(8)
                    << "[DotNetRuntime] Failed to get pointer to function \""
                    << typeName << "." << functionName << "\" in assembly (" << assemblyName << "). "
                    << "Error 0x" << result << "\n";
                throw std::runtime_error(oss.str());
            }

            return managedDelegate;
        }
    }
}

We can then retrieve our EngineInterface::HelloWorld() function and call it:

// Application.cpp
#include <sstream>

...

void Application::Run()
{
    startScriptEngine(); 

    // Get the function
    void(*hwFunc)(void) = GetFunctionPtr<void(*)(void)>
    (
        "ScriptAPI",                 // Name of the Assembly
        "ScriptAPI.EngineInterface", // Full name of the class
        "HelloWorld"                 // Name of the function
    );
    // Call it
    hwFunc();

    ...
}

And that’s it! Run the application and we would have successfully executed our managed C++/CLI function from native C++ code.

Executing Native Functions

What about the other way round? One limitation is that since our Core application is hosting the CLR, all native code execution from the managed code does require our native code to kick start the chain. However, that’s not an issue for us since in a scripting system of a game engine, the C++-based game engine requires full control of execution of all code anyways.

To demonstrate this, we will modify our EngineInterface::HelloWorld() function and have it call our native Application::HelloWorld() function.

// EngineInterface.cxx
#include "EngineInterface.hxx"

#include "../Core/Application.h"
#pragma comment (lib, "Core.lib")

namespace ScriptAPI
{
    void EngineInterface::HelloWorld()
    {
        System::Console::WriteLine("Hello Managed World!");
        Core::Application::HelloWorld();
    }
}

Now when we run our application, we should get both “Hello World” messages!

Conclusion

So far, we’ve been able to call managed code from native code and vice versa. That said, C# is still not involved yet. We’re getting close and in the next part, we will link up the ScriptAPI and ManagedScripts project to start executing C# code.

A repository of a working solution based on this tutorial can be found here.

Categories: Tutorials

2 Comments

Borja · 20 September 2022 at 10:14 am

Really good series! Keep it up!

    Kah Wei · 24 September 2022 at 3:58 pm

    Thank you! I’m quite busy at the moment so the next part is going to take awhile but don’t worry, I’m still working on it. 🙂

Leave a Reply

Avatar placeholder

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.