Runtime Marshalling

How values are passed between plugins in different languages, and how a language module uses JitCallback and JitCall.

Every method of a plugin is a native function with the C calling convention of the platform. Marshalling is what a language module does to turn the values of its language into the arguments of such a function and back. This page lists the types, the passing rules, and the code generators the core provides for modules.

Types

Manifest typeC++ typePassed as
bool, char8, char16, integers, float, double, ptr64bool, char, char16_t, int8_t … uint64_t, float, double, void*Value
functionPointer to a native functionValue. Its signature is the prototype of the parameter
stringplg::stringPointer
anyplg::anyPointer
T[]plg::vector<T>Pointer
vec2, vec3, vec4, mat4x4plg::vec2, plg::vec3, plg::vec4, plg::mat4x4Pointer
Any type with "ref": trueT&Pointer: the callee can change the value and the caller sees the change

On 32-bit platforms the pointer type is called ptr32. An enum parameter is passed as its integer type.

The full list of types with their C++ names:

C++ TypePlugify AliasRef Support ?
voidvoid❌
boolbool✅
charchar8✅
char16_tchar16✅
int8_tint8✅
int16_tint16✅
int32_tint32✅
int64_tint64✅
uint8_tuint8✅
uint16_tuint16✅
uint32_tuint32✅
uint64_tuint64✅
void*ptr64✅
floatfloat✅
doubledouble✅
void*function❌
plg::stringstring✅
plg::anyany✅
plg::vector<bool>bool✅
plg::vector<char>char8✅
plg::vector<char16_t>char16✅
plg::vector<int8_t>int8✅
plg::vector<int16_t>int16✅
plg::vector<int32_t>int32✅
plg::vector<int64_t>int64✅
plg::vector<uint8_t>uint8✅
plg::vector<uint16_t>uint16✅
plg::vector<uint32_t>uint32✅
plg::vector<uint64_t>uint64✅
plg::vector<void*>ptr64✅
plg::vector<float>float✅
plg::vector<double>double✅
plg::vector<plg::string>string✅
plg::vector<plg::any>any✅
plg::vector<plg::vec2>vec2✅
plg::vector<plg::vec3>vec3✅
plg::vector<plg::vec4>vec4✅
plg::vector<plg::mat4x4>mat4x4✅
plg::vec2vec2✅
plg::vec3vec3✅
plg::vec4vec4✅
plg::mat4x4mat4x4✅

Return values

Small values come back in registers. Larger ones are written to memory the caller provides, which is passed as a hidden first argument:

  • always for string, any, every array and mat4x4;
  • for vec3 and vec4 on Windows; on Linux and macOS (x86-64 System V and ARM64) they come back in registers.

vec2 and all scalars come back in registers everywhere. JitCallback and JitCall handle this by themselves; it matters only when you write the native code by hand.

The plg containers

plg::string, plg::vector, plg::any and the vector types come from the headers of the core (include/plg/). They have the same memory layout whatever compiler or standard library builds the module, which is why they, and not std::string or std::vector, cross the boundary between plugins. A module can create and destroy them in its own C++ code and give the language only pointers and plain data. Languages that cannot run C++ constructors describe the same layout in their own terms, for example the plugin packages for C#, Go and Rust.

Generating functions at run time

The core includes two code generators built on asmjit. They are part of the plugify library, nothing else has to be linked. Both work from a Method, the description of a signature in the manifest, so they follow the calling convention, the hidden return argument and ref parameters of that method.

ClassGeneratesUsed for
JitCallbackA native function that collects its arguments and calls your handlerExporting methods of your plugins, and passing functions of your language as function parameters
JitCallA function that takes an array of arguments and calls a native function with themCalling methods of other plugins and function values your plugins receive

Each generated function lives as long as the JitCallback or JitCall object that made it, so keep these objects for as long as the function can be called.

JitCallback

plugify/callback.hpp
using CallbackHandler = void (*)(
    const Method* method,  // the signature
    Address data,          // the value passed to GetJitFunc
    uint64_t* params,      // one 64-bit slot per argument
    size_t count,          // number of slots
    void* ret              // where to write the return value
);

Address GetJitFunc(const Method& method, CallbackHandler callback, Address data = nullptr,
                   HiddenParam hidden = &ValueUtils::IsHiddenParam);

Each argument takes one slot. Scalars are stored in the slot; strings, arrays, vectors and ref parameters are stored as pointers. ParametersSpan and ReturnSlot read and write the slots:

// The handler behind a method `int32 Add(int32 a, int32 b)`
static void InternalCall(const Method* method, Address data, uint64_t* p, size_t count, void* r) {
    ParametersSpan params(p, count);
    ReturnSlot ret(r, sizeof(int32_t));
    int32_t a = params.Get<int32_t>(0);
    int32_t b = params.Get<int32_t>(1);
    ret.Set<int32_t>(a + b);
}

JitCallback callback;
Address func = callback.GetJitFunc(method, &InternalCall, userData);
if (!func) {
    // callback.GetError() says why
}

data usually points to the object of your language that implements the method, for example a Python function, so one handler can serve every method. When the method returns an object through the hidden argument, p[0] is the memory to construct the result in; ReturnSlot::Construct<T>() helps with that.

JitCall

plugify/call.hpp
using CallingFunc = void (*)(const uint64_t* params, const Return* ret);

Address GetJitFunc(const Method& method, Address target, WaitType waitType = WaitType::None,
                   HiddenParam hidden = &ValueUtils::IsHiddenParam);

Fill Parameters with one value per argument, call the generated function and read the result from Return:

// Call `int32 Add(int32 a, int32 b)` of another plugin
JitCall call;
Address func = call.GetJitFunc(method, target);

Parameters params(2);
params.Add(int32_t{ 2 }).Add(int32_t{ 3 });

Return ret;
func.As<JitCall::CallingFunc>()(params.Get(), &ret);
int32_t sum = ret.Get<int32_t>();  // 5

For a string argument add a pointer to a plg::string; for a method that returns a string, add a pointer to the memory for the result as the first argument and construct nothing there yourself: the callee does it. WaitType::Breakpoint and WaitType::Wait_Keypress stop before the call, which helps when debugging a module.

Calling conventions and variadic methods

By default methods use the C calling convention of the platform. callConv in a method of the manifest sets another one (cdecl, stdcall, fastcall, vectorcall, thiscall, x64systemv, x64windows and others) for code that needs it. varIndex is the index of the first variadic parameter. Both are taken into account by JitCall and JitCallback; native languages call such functions directly.

Tips

  • Calls between two plugins of the same language can skip marshalling: the module knows both sides and can call the plugin code directly, as the C# module does for C# plugins.
  • Create the JitCallback and JitCall objects once, when a plugin is loaded or exported, not on every call.
  • A function value received from another plugin is just an address. Generate a JitCall for it with the prototype of the parameter before calling it from your language.
  • Test the module with the cross-call tests, see Writing Language Module.