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.
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. 🙂