Создание языкового модуля

Как добавить в Plugify поддержку нового языка с помощью языкового модуля.

Языковой модуль — нативная библиотека, которая учит ядро одному языку: загружает написанные на нём плагины, запускает и останавливает их и связывает их методы с методами плагинов на других языках. На этой странице описано, что должен делать модуль и как его начать.

Быстрый старт

Клонируйте шаблон вместе с сабмодулями

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

Соберите его

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

Заполните модуль

Реализуйте методы в src/module.cpp и укажите язык в plugify-module-template.pmodule.in.

В шаблоне есть ядро в виде сабмодуля external/plugify, класс модуля с пустыми методами, шаблон манифеста, списки экспорта для компоновщика в sym/, conda-рецепт в conda/ и GitHub-workflow для сборки релизов. Готовые модули (C++, Python, Lua и другие) служат полными примерами.

Манифест модуля

Модуль находится по манифесту *.pmodule:

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 связывает плагины с модулем: плагин с "language": "demo" загружает этот модуль, и ядро делает плагин зависимым от модуля. Библиотека модуля по умолчанию — bin/ плюс имя файла манифеста, здесь bin/libdemo_module.so на Linux и bin/demo_module.dll на Windows. Поле runtime задаёт другой путь, а directories добавляет папки, где ищутся зависимости самой библиотеки. Все поля описаны в Архитектуре.

Точка входа

Библиотека должна экспортировать одну C-функцию. Ядро вызывает её один раз после загрузки библиотеки:

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

PLUGIFY_MODULE_API здесь означает макрос экспорта вашей библиотеки; в шаблоне это LM_EXPORT. Верните указатель на объект, который живёт столько же, сколько библиотека, например глобальный.

Интерфейс ILanguageModule

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;
    };
}

Все методы, кроме IsDebugBuild, возвращают Result, то есть std::expected<T, std::string>. Чтобы сообщить об ошибке, верните plugify::MakeError("текст {}", args...); ядро пометит расширение как упавшее с этим текстом. Исключения, выброшенные из этих методов, ядро перехватывает и обрабатывает так же.

МетодКогда ядро его вызывает
InitializeОдин раз после загрузки модуля. Сохраните Provider: через него доступны логирование, папки хоста и список расширений
OnPluginLoadДля каждого плагина вашего языка в порядке загрузки. Загрузите плагин и верните его методы
OnMethodExportДля каждого плагина с методами, после загрузки всех плагинов. Вызывается у каждого модуля, на каком бы языке ни был плагин
OnPluginStartДля каждого плагина вашего языка после всех экспортов
OnUpdate, OnPluginUpdateИз Update хоста, если в MethodTable установлен соответствующий флаг
OnPluginEndДля каждого плагина вашего языка при завершении, в обратном порядке загрузки
ShutdownОдин раз при выгрузке модуля
IsDebugBuildДолжен возвращать true для отладочной сборки. На Windows ядро не загружает модуль, собранный в другой конфигурации, чем оно само

MethodTable

MethodTable сообщает ядру, какие необязательные вызовы есть, чтобы оно не вызывало пустые:

ПолеГде возвращаетсяСмысл
hasUpdateInitDataМодулю нужен OnUpdate
hasUpdateLoadDataПлагину нужен OnPluginUpdate
hasStart, hasEndLoadDataПлагину нужны OnPluginStart, OnPluginEnd
hasExportLoadDataУ плагина есть методы, поэтому для него вызывается OnMethodExport

Что возвращает OnPluginLoad

  • methods: для каждого элемента plugin.GetMethods(), в том же порядке, MethodData со ссылкой на этот Method и нативным адресом его реализации. По funcName метода модуль находит реализацию. Если список не совпадает с манифестом или адрес нулевой, ядро отклоняет плагин.
  • data: любой указатель, который вы хотите хранить для этого плагина. Ядро возвращает его как plugin.GetUserData().
  • table: флаги, описанные выше.

Файлы плагина лежат в plugin.GetLocation(), а его entry из манифеста — plugin.GetEntry().

Связь между языками

Каждый метод каждого плагина — нативная функция с соглашением о вызовах C данной платформы. Модуль работает в двух направлениях:

  • Экспорт. В OnPluginLoad модуль отдаёт ядру нативный адрес каждого метода своего плагина. Нативный язык может отдать адрес самой функции. Остальным языкам нужна нативная функция, которая преобразует аргументы и вызывает код плагина; её во время выполнения генерирует JitCallback.
  • Импорт. В OnMethodExport(plugin) модуль получает плагин, методы которого лежат в plugin.GetMethodsData(), и делает их доступными своим плагинам. Нативный язык может вызвать адрес напрямую. Остальные вызывают его через функцию, сгенерированную JitCall, предварительно преобразовав свои значения в типы Plugify.

Существующие модули импортируют методы так, как принято в их языке:

МодульКак плагин P видит метод M другого плагина
C++, Rust, GoПеременная-указатель на функцию __P_M в библиотеке плагина, её заполняет модуль
C# (.NET)Сгенерированный класс P.P с методом M
PythonМодуль plugify.pps.P
JavaScriptES-модуль :P
LuaТаблица P

Все плагины загружаются и экспортируются до запуска первого из них, поэтому в OnPluginStart все импорты уже заполнены.

Минимальный модуль

Этот модуль обслуживает язык demo. Для каждого метода плагина он генерирует нативную функцию, складывающую два значения int32, а при запуске плагина вызывает каждый метод через 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;
}

С плагином, который объявляет метод Add(int32 a, int32 b) -> int32 и "language": "demo", plug выводит demo_plugin.Add(2, 3) = 5.

Тестирование

  • Положите модуль и несколько плагинов в папку extensions приложения plug и проверьте, что они доходят до Running.
  • Вызовы между языками проверяйте плагинами cross_call_master и cross_call_worker из репозитория C++-модуля: напишите worker на своём языке с теми же методами, что у C++-версии, загрузите его вместе с master и C++-модулем, и master проверит все виды параметров. См. Инструкции по сборке.
  • На Windows проверяйте и отладочную, и релизную сборку с ядром той же конфигурации.

Публикация

Модули распространяются conda-пакетами, так же как плагины. Workflow шаблона использует release-please: при релизе он собирает модуль для Linux и Windows, делает conda-пакеты по рецепту из conda/ и прикладывает их к релизу на GitHub. Пакет должен класть манифест в корень окружения, а библиотеку в bin/, то есть в ту же раскладку, которую ядро ожидает в extensions.