Welcome back to another post on building a C# scripting engine. Today, we’ll look at how we can avoid bundling the 200+ .NET Runtime DLLs with our application. Instead, we will load the DLLs that the user may have already installed on their own system.

This is the last 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.

Why Use an Installed Runtime?

So why would we want to do this? Currently, we are bundling 200+ .NET Runtime DLLs with our application which results in about an extra 70 MB for our application package. Frankly, this also makes our executable’s directory pretty messy. After all, between these two pictures, isn’t the one on the right cleaner?

For DigiPen students who are uploading their games on to the DigiPen Games Gallery, this is also a potential issue that may prevent your game from being approved. So it’s good to clean up all these DLLs anyways.

.NET Runtime Installer

Since we are now relying on the user’s own copy of the .NET Runtime, we need them to install it first. You can retrieve the appropriate version from Microsoft’s website and bundle it with your application. That said, .NET Runtime versions are generally forwards compatible and you should have no problem running, for example, our compiled .NET 6.0 code on a .NET 7.0 runtime.

In our case, even though the latest version is .NET 7.0, this tutorial follows .NET 6.0 for now and so we will grab the latest release for .NET 6.0 (6.0.24 as of writing). Since our sample application only supports x64, we will send our users the x64 installer but if you support other architectures, feel free to grab the others instead.

Do note that, since we have the SDK installed, we do not need this installer as the SDK already includes the binaries. However, your users will need to install this.

Cleaning Up

To start things off, let’s clean up our project and remove the bundling of the .NET Runtime DLLs.

First let’s delete all the .DLLs from our extern folder. Then, let’s remove the post-build command to copy those DLLs into our output directory.

Finding the Installed Runtime

Next, we will need to find where the user has installed the runtime. Remember the Trusted Platform Assemblies (TPA) list that we have filled up in the third tutorial? That list tells the .NET Runtime where and which DLLs it can load to retrieve and assembly. Currently, what we have done is grab all DLLs in the executable directory. However, now we will also need to add the installed .NET Runtime’s DLLs.

Thankfully, on Windows, this is somewhat simple. Both the .NET SDK and Runtime is always installed to C:\Program Files\dotnet with the binaries in C:\Program Files\dotnet\shared\. If you take a peak at this folder, you’ll see that there are different types of runtimes. However, the only one we are looking for is Microsoft.NETCore.App as this has all we need. The rest contain additional libraries that aren’t required like server hosting and more.

Some people might have a lot of different versions installed…

Getting All Installed Runtime Versions

If you then peak in the Microsoft.NETCore.App folder, you might see more than one folder with a naming convention that follows semantic versioning. Users may have multiple versions of the .NET Runtime installed so we need to pick a suitable one. Since the runtime is forward compatible, we an simply grab the latest version. So let’s do this in code.

First, let’s declare a function getDotnetRuntimePath():

// Application.h
...
namespace Core
{
    ...
    class DLL_API Application
    {
        ...
        private:
        ...
        std::string getDotNetRuntimePath() const;
        ...
    }
}

Then we’ll implement it. Note that this is a fairly simple implementation for naively checking for the latest version. In your implementation, you should implement proper semantic version checking (major, minor, revision) to retrieve the latest version.

// Application.cpp
...
namespace Core
{
    ...
    std::string Application::getDotNetRuntimePath() const
    {
        // Check if any .NET Runtime is even installed
        const std::filesystem::path PATH = 
            std::filesystem::path("C:/Program Files/dotnet/shared/Microsoft.NETCore.App");
        if (!std::filesystem::exists(PATH))
            return "";

        // Check all folders in the directory to find versions
        std::pair<int, std::filesystem::path> latestVer = { -1, {} };
        for (const auto& DIR_ENTRY : std::filesystem::directory_iterator(PATH))
        {
            // Is a file, not a folder
            if (!DIR_ENTRY.is_directory())
                continue;

            // Get the directory's name
            const auto& DIR = DIR_ENTRY.path();
            const auto& DIR_NAME = (--(DIR.end()))->string();
            if (DIR_NAME.empty())
                continue;

            // Get the version number
            const int VER_NUM = DIR_NAME[0] - '0';

            // We will only naively check major version here and ignore the rest of 
            // semantic versioning to keep things simple for this sample.
            if (VER_NUM > latestVer.first)
            {
                latestVer = { VER_NUM, DIR };
            }
        }

        // Check if we found any valid versions
        if (latestVer.first >= 0)
        {
            // Replace all forward slashes with backslashes 
            // (.NET can't handle forward slashes)
            auto dotnetPath = latestVer.second.string();
            std::replace_if
            (
                dotnetPath.begin(), dotnetPath.end(), 
                [](char c){ return c == '/'; }, 
                '\\'
            );
            return dotnetPath;
        }

        return "";
    }
}

We will then call this function and check if we have successfully retrieved the path to the runtime. If it so happens that there is no runtime, we should just throw an exception as there is no way we can continue.

// Application.cpp
...
namespace Core
{
    ...
    void Application::startScriptEngine()
    {
        // Get the .NET Runtime's path first
        const auto DOT_NET_PATH = getDotNetRuntimePath();
        if (DOT_NET_PATH.empty())
            throw std::runtime_error("Failed to find .NET Runtime.");
        ...
    }
}

Now that we have the path, let’s modify our code to use the binaries from this directory instead.

Loading the Installed Runtime

Loading the .NET Runtime

Remember that we had to first load the coreclr.dll library into our application? Since we are no longer bundling coreclr.dll with our application, we’ll change the path we provide to LoadLibraryExA() to use the path that we had received.

// Application.cpp
...
namespace Core
{
    ...
    void Application::startScriptEngine()
    {
        ...
        // Construct the CoreCLR path
        const std::string CORE_CLR_PATH = DOT_NET_PATH + "\\coreclr.dll";

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

Building the new TPA List

Before we start with building the new TPA list, we need to fix a bug with buildTPAList(). Previously, we had saved SEARCH_PATH as a static constant, this means that subsequent calls will keep searching the same directory. So let’s just remove the static.

// Application.cpp
namespace Core
{
    ...
    std::string Application::buildTpaList(const std::string& directory)
    {
        // Constants
        const std::string SEARCH_PATH = directory + "\\*.dll";
        static constexpr char PATH_DELIMITER = ';';
        ...
    }
    ...
}

Now let’s modify how we created the TPA list by adding the .NET Runtime path as another search directory.

// Application.cpp
namespace Core
{
    ...
    void Application::startScriptEngine()
    {
        ...
        // Step 3: Construct AppDomain properties used when starting the runtime
        std::string tpaList = buildTpaList(runtimePath) + buildTpaList(DOT_NET_PATH);
        ...
    }
}

And that is pretty much it. Do make sure that you delete your build folders manually since Visual Studio only deletes files that it has generated. Any files copied via post build commands like the existing .NET DLLs we copied over will not be automatically deleted by using Visual Studio’s “Clean Solution” command.

Conclusion

As always, the repository of a working solution based on this tutorial can be found here on GitHub.

With this post, we’ve reached the end of my C# Scripting Tutorial series. I hope all of it has helped you guys in whatever project you may be working on. I might make additional posts in future on other topics related to it if I have the time but I don’t have any plans for that at the moment. :>

Although if anyone has any ideas, feel free to shoot me a message, I might give it a shot. That’s it for now, see you bye bye!

Categories: Tutorials

0 Comments

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.