Writing Language Module

How to add support for a new language to Plugify with a language module.

A language module is a native library that teaches the core one language: it loads plugins written in it, starts and stops them, and connects their methods with the methods of plugins in other languages. This page describes what a module has to do and how to start one.

Quick start

Clone the template with its submodules

git clone https://github.com/untrustedmodders/plugify-module-template.git --recursive
cd plugify-module-template

Build it

cmake --preset Release
cmake --build --preset Release

Fill in the module

Implement the methods in src/module.cpp and set the language in plugify-module-template.pmodule.in.

The template has the core as a submodule in external/plugify, the module class with empty methods, the manifest template, export lists for the linker in sym/, a conda recipe in conda/ and a GitHub workflow that builds releases. The existing modules (C++, Python, Lua and others) are full examples.

The module manifest

A module is found by its *.pmodule manifest:

demo_module.pmodule
{
  "$schema": "https://raw.githubusercontent.com/untrustedmodders/plugify/refs/heads/main/schemas/module.schema.json",
  "name": "demo_module",
  "version": "1.0.0",
  "description": "Adds support for Demo plugins",
  "author": "Your Name",
  "website": "https://example.com",
  "license": "MIT",
  "language": "demo",
  "platforms": []
}

language is the key that connects plugins to the module: a plugin with "language": "demo" is loaded by this module, and the core makes it depend on the module. The library of the module is bin/ plus the name of the manifest file by default, here bin/libdemo_module.so on Linux and bin/demo_module.dll on Windows. Set runtime to use another path, and directories to add folders where the library's own dependencies are searched. All fields are described in Architecture.

Entry point

The library must export one C function. The core calls it once after loading the library:

extern "C" PLUGIFY_MODULE_API plugify::ILanguageModule* GetLanguageModule();

PLUGIFY_MODULE_API stands for the export macro of your library; in the template it is LM_EXPORT. Return a pointer to an object that lives as long as the library, for example a global.

The ILanguageModule interface

plugify/language_module.hpp
namespace plugify {
    struct InitData {
        MethodTable table;
    };

    struct LoadData {
        std::vector<MethodData> methods;
        Address data;
        MethodTable table;
    };

    class ILanguageModule {
    protected:
        ~ILanguageModule() = default;

    public:
        virtual Result<InitData> Initialize(const Provider& provider, const Extension& module) = 0;
        virtual Result<void> Shutdown() = 0;
        virtual Result<void> OnUpdate(std::chrono::milliseconds deltaTime) = 0;
        virtual Result<LoadData> OnPluginLoad(const Extension& plugin) = 0;
        virtual Result<void> OnPluginStart(const Extension& plugin) = 0;
        virtual Result<void> OnPluginUpdate(const Extension& plugin, std::chrono::milliseconds deltaTime) = 0;
        virtual Result<void> OnPluginEnd(const Extension& plugin) = 0;
        virtual Result<void> OnMethodExport(const Extension& plugin) = 0;
        virtual bool IsDebugBuild() const noexcept = 0;
    };
}

All methods except IsDebugBuild return Result, which is std::expected<T, std::string>. To report a failure, return plugify::MakeError("text {}", args...); the core marks the extension as failed with that text. Exceptions thrown from these methods are caught by the core and treated the same way.

MethodWhen the core calls it
InitializeOnce, after loading the module. Keep the Provider: it gives you logging, the folders of the host and the list of extensions
OnPluginLoadFor each plugin of your language, in load order. Load the plugin and return its methods
OnMethodExportFor each plugin that has methods, after all plugins are loaded. Called for every module, whatever the language of the plugin
OnPluginStartFor each plugin of your language, after all exports
OnUpdate, OnPluginUpdateFrom the host's Update, if the matching flag in MethodTable is set
OnPluginEndFor each plugin of your language on shutdown, in reverse load order
ShutdownOnce, when the module is unloaded
IsDebugBuildMust return true for a debug build. On Windows the core refuses a module built in another configuration than itself

MethodTable

MethodTable tells the core which optional calls exist, so it does not call empty ones:

FieldReturned inMeaning
hasUpdateInitDataThe module wants OnUpdate
hasUpdateLoadDataThe plugin wants OnPluginUpdate
hasStart, hasEndLoadDataThe plugin wants OnPluginStart, OnPluginEnd
hasExportLoadDataThe plugin has methods, so OnMethodExport is called for it

What OnPluginLoad returns

  • methods: for each entry of plugin.GetMethods(), in the same order, a MethodData with a reference to that Method and the native address of its implementation. funcName of the method tells you where to find the implementation. If the list does not match the manifest or an address is null, the core rejects the plugin.
  • data: any pointer you want to keep for this plugin. The core gives it back as plugin.GetUserData().
  • table: the flags described above.

The plugin's files are in plugin.GetLocation(), and its entry from the manifest is plugin.GetEntry().

Connecting languages

Every method of every plugin is a native function with the C calling convention of the platform. A module works in two directions:

  • Exporting. In OnPluginLoad the module gives the core a native address for each method of its plugin. A native language can give the address of the function itself. Other languages need a native function that converts the arguments and calls the plugin code; JitCallback generates one at run time.
  • Importing. In OnMethodExport(plugin) the module receives a plugin whose methods are in plugin.GetMethodsData(), and makes them callable from its own plugins. A native language can call the address directly. Other languages call it through a function generated by JitCall, after converting their values to Plugify types.

The existing modules import methods in the way that is natural for their language:

ModuleHow plugin P sees method M of another plugin
C++, Rust, GoA function pointer variable __P_M in the plugin library, filled in by the module
C# (.NET)A generated class P.P with the method M
PythonModule plugify.pps.P
JavaScriptES module :P
LuaTable P

All plugins are loaded and exported before the first one is started, so in OnPluginStart all imports are filled in.

A minimal module

This module serves the language demo. For each method of a plugin it generates a native function that adds two int32 values, and when the plugin starts it calls each method through JitCall:

module.cpp
#include <plugify/language_module.hpp>
#include <plugify/provider.hpp>
#include <plugify/extension.hpp>
#include <plugify/method.hpp>
#include <plugify/callback.hpp>
#include <plugify/call.hpp>
#include <deque>
#include <format>

using namespace plugify;

static const Provider* g_provider;

// Called by the generated function of every 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));
    ret.Set<int32_t>(params.Get<int32_t>(0) + params.Get<int32_t>(1));
}

class DemoModule final : public ILanguageModule {
public:
    Result<InitData> Initialize(const Provider& provider, const Extension& module) override {
        g_provider = &provider;
        return InitData{};
    }

    Result<void> Shutdown() override { return {}; }
    Result<void> OnUpdate(std::chrono::milliseconds) override { return {}; }

    Result<LoadData> OnPluginLoad(const Extension& plugin) override {
        std::vector<MethodData> methods;
        for (const auto& method : plugin.GetMethods()) {
            auto& callback = _callbacks.emplace_back();
            Address addr = callback.GetJitFunc(method, &InternalCall);
            if (!addr) {
                return MakeError("Cannot export {}: {}", method.GetName(), callback.GetError());
            }
            methods.push_back({ method, addr });
        }
        return LoadData{
            .methods = std::move(methods),
            .data = nullptr,
            .table = { .hasStart = true, .hasExport = !plugin.GetMethods().empty() }
        };
    }

    Result<void> OnPluginStart(const Extension& plugin) override {
        for (const auto& [method, addr] : plugin.GetMethodsData()) {
            JitCall call;
            Address func = call.GetJitFunc(method, addr);
            Parameters params(2);
            params.Add(int32_t{ 2 }).Add(int32_t{ 3 });
            Return ret;
            func.As<JitCall::CallingFunc>()(params.Get(), &ret);
            g_provider->LogInfo(std::format("{}.{}(2, 3) = {}", plugin.GetName(), method.GetName(), ret.Get<int32_t>()));
        }
        return {};
    }

    Result<void> OnPluginUpdate(const Extension&, std::chrono::milliseconds) override { return {}; }
    Result<void> OnPluginEnd(const Extension&) override { return {}; }
    Result<void> OnMethodExport(const Extension&) override { return {}; }
    bool IsDebugBuild() const noexcept override { return false; }

private:
    std::deque<JitCallback> _callbacks; // generated functions live as long as these objects
};

static DemoModule g_module;

extern "C" PLUGIFY_MODULE_API ILanguageModule* GetLanguageModule() {
    return &g_module;
}

With a plugin that declares a method Add(int32 a, int32 b) -> int32 and "language": "demo", plug prints demo_plugin.Add(2, 3) = 5.

Testing

  • Put the module and a few plugins into the extensions folder of plug and check that they reach Running.
  • Test calls between languages with cross_call_master and cross_call_worker from the C++ module repository: write a worker in your language with the same methods as the C++ one, load it together with the master and the C++ module, and the master checks every kind of parameter. See Build Instructions.
  • On Windows, test both debug and release builds against a core of the same configuration.

Publishing

Modules are distributed as conda packages, the same way as plugins. The template's workflow uses release-please: on a release it builds the module for Linux and Windows, makes conda packages from the recipe in conda/ and attaches them to the GitHub release. The package must put the manifest in the root of the environment and the library in bin/, the same layout the core expects in extensions.