The C++ Scripting series continues today by going over some internal improvements that don’t add any features, but make the existing system more robust. We’ve lucked out in a couple of areas and today we’ll take the opportunity to fix them and learn about some inner workings of C++ and C# along the way.

Table of Contents

Init Parameters

The first change for today is in how the native plugin is initialized. Previously, we’d pass a few fixed parameters to Init along with a ton of code-generated parameters. These were mostly function pointers for C++ to call when it wanted to invoke C# binding functions, but there were also some other parameters such as sizes of free lists. Here’s an example of what Init has looked like until now:

DLLEXPORT void Init(
    uint8_t* memory,
    int32_t memorySize,
    void (*releaseObject)(int32_t handle),
    int32_t (*stringNew)(const char* chars),
    void (*setException)(int32_t handle),
    int32_t (*arrayGetLength)(int32_t handle),
    int32_t (*enumerableGetEnumerator)(int32_t handle),
    /*BEGIN INIT PARAMS*/
    int32_t maxManagedObjects,
    void (*releaseSystemDecimal)(int32_t handle),
    int32_t (*systemDecimalConstructorSystemDouble)(double value),
    int32_t (*systemDecimalConstructorSystemUInt64)(uint64_t value),
    int32_t (*boxDecimal)(int32_t valHandle),
    int32_t (*unboxDecimal)(int32_t valHandle),
    UnityEngine::Vector3 (*unityEngineVector3ConstructorSystemSingle_SystemSingle_SystemSingle)(float x, float y, float z),
    UnityEngine::Vector3 (*unityEngineVector3Methodop_AdditionUnityEngineVector3_UnityEngineVector3)(UnityEngine::Vector3& a, UnityEngine::Vector3& b),
    int32_t (*boxVector3)(UnityEngine::Vector3& val),
    UnityEngine::Vector3 (*unboxVector3)(int32_t valHandle),
    /*END INIT PARAMS*/
    InitMode initMode)

All the parameters between BEGIN INIT PARAMS and END INIT PARAMS are code-generated, so the number of parameters will grow as more and more C# functionality is exposed via the JSON config. C++ requires that at least 256 parameters are supported in function calls, which is excessive for hand-written code. However, the limit will easily be reached with the code generator adding more and more parameters to Init.

To work around this, we need to move the parameters somewhere else. Thankfully, we’re already passing the memory parameter. C# can store these code-generated parameters at the start of memory and C++ can read them back out. We’re essentially passing parameters manually using the heap rather than in the normal way using the stack. The first step is to remove all the code-generated parameters from the signature of Init so it looks like this now:

DLLEXPORT void Init(
    uint8_t* memory,
    int32_t memorySize,
    InitMode initMode)

Now there are only three parameters, so we’re well under the potential limit of 256. Next, we read the “parameters” out of memory in Init:

uint8_t* curMemory = memory;
 
Plugin::ReleaseObject = *(void (**)(int32_t handle))curMemory;
curMemory += sizeof(Plugin::ReleaseObject);
 
Plugin::StringNew = *(int32_t (**)(const char*))curMemory;
curMemory += sizeof(Plugin::StringNew);
 
Plugin::SetException = *(void (**)(int32_t))curMemory;
curMemory += sizeof(Plugin::SetException);
 
Plugin::ArrayGetLength = *(int32_t (**)(int32_t))curMemory;
curMemory += sizeof(Plugin::ArrayGetLength);
 
Plugin::EnumerableGetEnumerator = *(int32_t (**)(int32_t))curMemory;
curMemory += sizeof(Plugin::EnumerableGetEnumerator);

We use curMemory as a pointer that moves forward after each parameter is read. The same technique works for arrays, such as the int32_t arrays that store reference counts:

Plugin::RefCountsSystemDecimal = (int32_t*)curMemory;
curMemory += 1000 * sizeof(int32_t);
Plugin::RefCountsLenSystemDecimal = 1000;

On the C# side, we also change the editor and player definitions of Init to only have three parameters:

// Editor definition used for hot reloading
delegate void InitDelegate(IntPtr memory, int memorySize, InitMode initMode);
 
// Player version used for direct calls
[DllImport(PLUGIN_NAME)]
static extern void Init(IntPtr memory, int memorySize, InitMode initMode);

Now we can stop passing all those parameters directly and instead pass them by writing them into memory:

int curMemory = 0;
Marshal.WriteIntPtr(
    memory,
    curMemory,
    Marshal.GetFunctionPointerForDelegate(ReleaseObjectDelegate));
curMemory += IntPtr.Size;
 
Marshal.WriteIntPtr(
    memory,
    curMemory,
    Marshal.GetFunctionPointerForDelegate(StringNewDelegate));
curMemory += IntPtr.Size;
 
Marshal.WriteIntPtr(
    memory,
    curMemory,
    Marshal.GetFunctionPointerForDelegate(SetExceptionDelegate));
curMemory += IntPtr.Size;
 
Marshal.WriteIntPtr(
    memory,
    curMemory,
    Marshal.GetFunctionPointerForDelegate(ArrayGetLengthDelegate));
curMemory += IntPtr.Size;
 
Marshal.WriteIntPtr(
    memory,
    curMemory,
    Marshal.GetFunctionPointerForDelegate(EnumerableGetEnumeratorDelegate));
curMemory += IntPtr.Size;

In this case, curMemory is an offset from the start of memory which allows us to pass it to Marshal functions like WriteIntPtr.

With these changes in place, we’re now avoiding passing too many parameters to Init and running into issues with C++ environements that don’t support extreme numbers of parameters. No changes are necessary to game code as this is implemented purely in the bindings layer and code generator.

Static Delegates

The next change also relates to the function pointers that C# passes to C++ via Init. So far, the call to Init has included a bunch of parameters that look like this:

Marshal.GetFunctionPointerForDelegate(new ReleaseObjectDelegate(ReleaseObject)),
Marshal.GetFunctionPointerForDelegate(new StringNewDelegate(StringNew)),
Marshal.GetFunctionPointerForDelegate(new SetExceptionDelegate(SetException)),
Marshal.GetFunctionPointerForDelegate(new ArrayGetLengthDelegate(ArrayGetLength)),
Marshal.GetFunctionPointerForDelegate(new EnumerableGetEnumeratorDelegate(EnumerableGetEnumerator)),

Each of these parameters creates a new delegate for a static method and then gets a function pointer for it. In C#, the function pointer is of type IntPtr but this is compatible with actual function pointer types like declared in C++. For example, the function pointer for Debug.Log takes an int handle for the object to log and returns void, so the C++ parameter is void (*debugLog)(int32_t).

There are two problems with this way of passing function pointers. First and foremost, C# isn’t retaining any references to the delegates it creates. So after it calls Init the garbage collector is free to collect those delegates. Now if C++ calls a function pointer that invokes the delegate that’s been garbage-collected, there may be problems such as crashes.

The second problem is that a new delegate is created for every function exposed to C# every time Init is called. As we’ve seen above, the parameter list can quickly grow to a very large size. Creating a delegate entails a managed allocation, which has the usual problems of being slow, causing heap fragmentation, and feeding the GC. So it’s best to only perform these allocations once, not every time the plugin is hot reloaded.

Both of these problems can be solved by creating the delegates only once and storing them in fields of the Bindings class. Since functions exposed to C++ must be static, these fields can likewise be static. Here’s how it looks when we move the delegate creation to fields:

static readonly ReleaseObjectDelegateType ReleaseObjectDelegate = new ReleaseObjectDelegateType(ReleaseObject);
static readonly StringNewDelegateType StringNewDelegate = new StringNewDelegateType(StringNew);
static readonly SetExceptionDelegateType SetExceptionDelegate = new SetExceptionDelegateType(SetException);
static readonly ArrayGetLengthDelegateType ArrayGetLengthDelegate = new ArrayGetLengthDelegateType(ArrayGetLength);
static readonly EnumerableGetEnumeratorDelegateType EnumerableGetEnumeratorDelegate = new EnumerableGetEnumeratorDelegateType(EnumerableGetEnumerator);

Now the Init call can use these, as we’ve seen above in the first part:

Marshal.GetFunctionPointerForDelegate(ReleaseObjectDelegate)
Marshal.GetFunctionPointerForDelegate(StringNewDelegate)
Marshal.GetFunctionPointerForDelegate(SetExceptionDelegate)
Marshal.GetFunctionPointerForDelegate(ArrayGetLengthDelegate)
Marshal.GetFunctionPointerForDelegate(EnumerableGetEnumeratorDelegate)
Conclusion

Today’s changes have made the project more able to handle the variability of systems that are outside of its direct control. By limiting the number of parameters passed to Init to a low and constant three, we’re effectively steering clear of potential C++ problems with handling more than 256 parameters. By never releasing references to the delegates we create and use function pointers for, we’re ensuring that the GC will never invalidate our bindings when it collects. Regardless of whether either of these problems actually occurred, we no longer need to worry about them when supporting new platforms or debugging weird language binding issues.

As usual, the GitHub project has been updated with all the changes from this article. If you’ve got any questions, feature requests, or bug fixes, feel free to leave a comment or file an issue. If you’d like to contribute a feature, bug fix, or optimization directly, feel free to send a pull request.