From 0a7f492f7cf1230cc2120cad2e90aa1c6bb5ea21 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Sun, 17 May 2026 19:19:27 +0200 Subject: [PATCH 1/5] Integrations: add server plugin SDK v1 (ABI, C++ wrapper, sample) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the public surface for out-of-tree native server plugins, loaded by a forthcoming ModuleManager in Server::Instance. This commit adds the ABI and a working sample only — no loader, no host vtable implementation, no Instance wiring. Intended as a reviewable contract before any runtime code is written. sdk/fw_plugin_abi.h Frozen C ABI for plugin authors. Opaque host/logger/player/response handles; append-only host vtable; required and optional plugin exports; manifest schema documented inline. abi_version=1. sdk/fw_plugin.hpp Header-only C++17 wrapper on top of the C ABI. Provides Logger, Player, HttpResponse, Host classes plus a FW_PLUGIN_DECLARE macro that emits the required C exports for a user-defined Plugin::Base subclass. Lambda registrations are heap-stored for plugin lifetime. sample/hello-plugin/ Minimal plugin exercising every v1 capability: scoped logger, console command, HTTP endpoint, player connect/disconnect callbacks. Builds standalone via CMake against the SDK headers only — no Framework link dependency. Verified to compile on macOS AppleClang and produce the six expected fw_plugin_* exports. Out of scope (Phase 1 and beyond): - ModuleManager / dlopen loader inside Server::Instance - server.json "modules" field parsing - manifest dependency-order resolution - SEH/try-catch fences at the C boundary - Networking RPC and scripting builtin registration - Flecs world access (Phase 3) - Client-side plugin loader (deferred) --- .../sample/hello-plugin/CMakeLists.txt | 36 ++ .../hello-plugin/hello-plugin.module.json | 8 + .../plugins/sample/hello-plugin/plugin.cpp | 56 ++++ .../server/plugins/sdk/fw_plugin.hpp | 309 ++++++++++++++++++ .../server/plugins/sdk/fw_plugin_abi.h | 239 ++++++++++++++ 5 files changed, 648 insertions(+) create mode 100644 code/framework/src/integrations/server/plugins/sample/hello-plugin/CMakeLists.txt create mode 100644 code/framework/src/integrations/server/plugins/sample/hello-plugin/hello-plugin.module.json create mode 100644 code/framework/src/integrations/server/plugins/sample/hello-plugin/plugin.cpp create mode 100644 code/framework/src/integrations/server/plugins/sdk/fw_plugin.hpp create mode 100644 code/framework/src/integrations/server/plugins/sdk/fw_plugin_abi.h diff --git a/code/framework/src/integrations/server/plugins/sample/hello-plugin/CMakeLists.txt b/code/framework/src/integrations/server/plugins/sample/hello-plugin/CMakeLists.txt new file mode 100644 index 000000000..d74738628 --- /dev/null +++ b/code/framework/src/integrations/server/plugins/sample/hello-plugin/CMakeLists.txt @@ -0,0 +1,36 @@ +# Hello-plugin: standalone sample. Builds against the Framework Plugin SDK +# headers only — no link dependency on the Framework itself. The output +# .dll/.so is loaded at runtime by the Framework server's plugin loader. +# +# Standalone build: +# cmake -S . -B build +# cmake --build build +# +# The result lives in build/ as hello-plugin.dll (Windows) or +# libhello-plugin.so (Linux/macOS). Drop it next to its manifest in the +# server's modules directory. + +cmake_minimum_required(VERSION 3.20) +project(hello-plugin CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_VISIBILITY_PRESET hidden) +set(CMAKE_VISIBILITY_INLINES_HIDDEN ON) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +add_library(hello-plugin SHARED plugin.cpp) + +# Resolve the SDK headers. When built from the in-tree location, the path +# is relative; when extracted as a template for an out-of-tree plugin, +# override with -DFW_PLUGIN_SDK_INCLUDE_DIR=... +set(FW_PLUGIN_SDK_INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../sdk" + CACHE PATH "Path to fw_plugin_abi.h and fw_plugin.hpp") +target_include_directories(hello-plugin PRIVATE "${FW_PLUGIN_SDK_INCLUDE_DIR}") + +# Strip the lib prefix on Unix so the on-disk name matches the manifest +# "entry" field across platforms. +set_target_properties(hello-plugin PROPERTIES + PREFIX "" + OUTPUT_NAME "hello-plugin" +) diff --git a/code/framework/src/integrations/server/plugins/sample/hello-plugin/hello-plugin.module.json b/code/framework/src/integrations/server/plugins/sample/hello-plugin/hello-plugin.module.json new file mode 100644 index 000000000..dae404827 --- /dev/null +++ b/code/framework/src/integrations/server/plugins/sample/hello-plugin/hello-plugin.module.json @@ -0,0 +1,8 @@ +{ + "name": "hello-plugin", + "version": "0.1.0", + "abi_version": 1, + "entry": "hello-plugin", + "depends_on": [], + "capabilities": ["commands", "http_endpoints", "player_events"] +} diff --git a/code/framework/src/integrations/server/plugins/sample/hello-plugin/plugin.cpp b/code/framework/src/integrations/server/plugins/sample/hello-plugin/plugin.cpp new file mode 100644 index 000000000..95081c8d4 --- /dev/null +++ b/code/framework/src/integrations/server/plugins/sample/hello-plugin/plugin.cpp @@ -0,0 +1,56 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +/* + * Hello-plugin: a minimal native plugin for the Framework server. + * Exercises every v1 host capability (logger, commands, HTTP endpoint, + * player connect/disconnect) and nothing else. + */ + +#include "fw_plugin.hpp" + +#include + +class HelloPlugin final : public Framework::Plugin::Base { + public: + int OnInit(Framework::Plugin::Host &host) override { + _log = host.LoggerFor("hello-plugin"); + _log.Info("hello-plugin loaded"); + + host.RegisterCommand("hello", "Print a greeting", [this](int argc, const char *const *argv) { + std::string who = argc > 1 ? argv[1] : "world"; + _log.Info("hello, " + who); + }); + + host.RegisterHttpEndpoint("/hello", [this](std::string_view /*method*/, std::string_view /*path*/, std::string_view /*body*/, Framework::Plugin::HttpResponse &response) { + response.SetStatus(200); + response.SetHeader("Content-Type", "text/plain"); + response.SetBody("hello from the plugin\n"); + _log.Debug("served /hello"); + }); + + host.OnPlayerConnect([this](Framework::Plugin::Player &player) { + _log.Info(player.Nickname() + " (guid=" + std::to_string(player.Guid()) + ") connected"); + }); + + host.OnPlayerDisconnect([this](Framework::Plugin::Player &player) { + _log.Info(player.Nickname() + " (guid=" + std::to_string(player.Guid()) + ") disconnected"); + }); + + return 0; + } + + void OnShutdown(Framework::Plugin::Host & /*host*/) override { + _log.Info("hello-plugin shutting down"); + } + + private: + Framework::Plugin::Logger _log; +}; + +FW_PLUGIN_DECLARE(HelloPlugin, "hello-plugin", "0.1.0") diff --git a/code/framework/src/integrations/server/plugins/sdk/fw_plugin.hpp b/code/framework/src/integrations/server/plugins/sdk/fw_plugin.hpp new file mode 100644 index 000000000..6aed1593f --- /dev/null +++ b/code/framework/src/integrations/server/plugins/sdk/fw_plugin.hpp @@ -0,0 +1,309 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +/* + * Framework Server Plugin SDK — C++ wrapper + * =========================================================================== + * + * Header-only C++17 ergonomics layered on top of fw_plugin_abi.h. Plugin + * authors get type-safe lambdas, RAII handles, and a single macro to wire + * up the required C exports. + * + * Minimal plugin: + * + * #include "fw_plugin.hpp" + * + * class MyPlugin : public Framework::Plugin::Base { + * public: + * int OnInit(Framework::Plugin::Host &host) override { + * auto log = host.LoggerFor("my-plugin"); + * log.Info("hello, framework"); + * + * host.RegisterCommand("greet", "Say hi to a player", + * [log](int argc, const char *const *argv) { + * log.Info(argc > 1 ? argv[1] : "anonymous"); + * }); + * + * host.OnPlayerConnect([log](Framework::Plugin::Player &p) { + * log.Info(p.Nickname() + " joined"); + * }); + * return 0; + * } + * }; + * + * FW_PLUGIN_DECLARE(MyPlugin, "my-plugin", "1.0.0") + * + * Callback storage: lambdas are heap-allocated and owned by the Host wrapper + * for the lifetime of the plugin. There is no Unregister API in v1 — all + * registrations live for the plugin's lifetime and are torn down on unload. + * =========================================================================== + */ + +#pragma once + +#include "fw_plugin_abi.h" + +#include +#include +#include +#include +#include + +namespace Framework::Plugin { + + /* --------------------------------------------------------------------- */ + /* Logger */ + /* --------------------------------------------------------------------- */ + + class Logger final { + public: + Logger() = default; + Logger(const FwHostVTable *vtable, FwLogger *logger): _vtable(vtable), _logger(logger) {} + + void Debug(const std::string &msg) const { + if (_vtable && _logger) _vtable->log_debug(_logger, msg.c_str()); + } + void Info(const std::string &msg) const { + if (_vtable && _logger) _vtable->log_info(_logger, msg.c_str()); + } + void Warn(const std::string &msg) const { + if (_vtable && _logger) _vtable->log_warn(_logger, msg.c_str()); + } + void Error(const std::string &msg) const { + if (_vtable && _logger) _vtable->log_error(_logger, msg.c_str()); + } + + private: + const FwHostVTable *_vtable = nullptr; + FwLogger *_logger = nullptr; + }; + + /* --------------------------------------------------------------------- */ + /* Player */ + /* --------------------------------------------------------------------- */ + + class Player final { + public: + Player(const FwHostVTable *vtable, FwPlayer *player, uint64_t guid): _vtable(vtable), _player(player), _guid(guid) {} + + uint64_t Guid() const { + return _guid; + } + + std::string Nickname() const { + if (!_vtable || !_player) return {}; + char stackBuf[128]; + size_t needed = _vtable->player_get_nickname(_player, stackBuf, sizeof(stackBuf)); + if (needed < sizeof(stackBuf)) { + return std::string(stackBuf, needed); + } + std::string heap(needed, '\0'); + _vtable->player_get_nickname(_player, heap.data(), heap.size() + 1); + return heap; + } + + private: + const FwHostVTable *_vtable = nullptr; + FwPlayer *_player = nullptr; + uint64_t _guid = 0; + }; + + /* --------------------------------------------------------------------- */ + /* HTTP response builder */ + /* --------------------------------------------------------------------- */ + + class HttpResponse final { + public: + HttpResponse(const FwHostVTable *vtable, FwHttpResponse *response): _vtable(vtable), _response(response) {} + + void SetStatus(int status) const { + _vtable->http_response_set_status(_response, status); + } + void SetBody(std::string_view body) const { + _vtable->http_response_set_body(_response, body.data(), body.size()); + } + void SetHeader(const std::string &key, const std::string &value) const { + _vtable->http_response_set_header(_response, key.c_str(), value.c_str()); + } + + private: + const FwHostVTable *_vtable; + FwHttpResponse *_response; + }; + + /* --------------------------------------------------------------------- */ + /* Host wrapper */ + /* --------------------------------------------------------------------- */ + + using CommandFn = std::function; + using HttpFn = std::function; + using PlayerEventFn = std::function; + + class Host final { + public: + explicit Host(FwHost *host): _host(host), _vtable(host ? host->vtable : nullptr) {} + + bool Valid() const { + return _host && _vtable && _vtable->abi_version == FW_PLUGIN_ABI_VERSION; + } + + Logger LoggerFor(const std::string &pluginName) const { + return Logger(_vtable, _vtable->logger_for(_host, pluginName.c_str())); + } + + bool RegisterCommand(const std::string &name, const std::string &description, CommandFn callback) { + auto slot = std::make_unique(std::move(callback)); + int rc = _vtable->register_command( + _host, name.c_str(), description.c_str(), + [](int argc, const char *const *argv, void *userdata) { + (*static_cast(userdata))(argc, argv); + }, + slot.get()); + if (rc == 0) { + _commandSlots.push_back(std::move(slot)); + return true; + } + return false; + } + + bool RegisterHttpEndpoint(const std::string &path, HttpFn callback) { + auto slot = std::make_unique(std::move(callback)); + int rc = _vtable->register_http_endpoint( + _host, path.c_str(), + [](const char *method, const char *path_c, const char *body, size_t body_len, FwHttpResponse *response, void *userdata) { + const FwHostVTable *vt = reinterpret_cast(userdata)->vtable; + HttpResponse wrappedResponse(vt, response); + (*reinterpret_cast(userdata)->fn)(method, path_c, std::string_view(body, body_len), wrappedResponse); + }, + MakeHttpBinding(slot.get())); + if (rc == 0) { + _httpSlots.push_back(std::move(slot)); + return true; + } + return false; + } + + bool OnPlayerConnect(PlayerEventFn callback) { + return RegisterPlayerEvent(_vtable->on_player_connect, std::move(callback)); + } + bool OnPlayerDisconnect(PlayerEventFn callback) { + return RegisterPlayerEvent(_vtable->on_player_disconnect, std::move(callback)); + } + + private: + /* + * The HTTP trampoline needs both the std::function* and the vtable* in + * its userdata. We bundle them in a tiny owned struct. + */ + struct HostBindings { + const FwHostVTable *vtable; + HttpFn *fn; + }; + std::vector> _httpBindings; + + HostBindings *MakeHttpBinding(HttpFn *fn) { + auto binding = std::make_unique(); + binding->vtable = _vtable; + binding->fn = fn; + HostBindings *raw = binding.get(); + _httpBindings.push_back(std::move(binding)); + return raw; + } + + bool RegisterPlayerEvent(int (*registerFn)(FwHost *, FwPlayerEventCallback, void *), PlayerEventFn callback) { + auto slot = std::make_unique(std::move(callback)); + auto binding = std::make_unique(); + binding->vtable = _vtable; + binding->fn = slot.get(); + int rc = registerFn( + _host, + [](FwPlayer *player, uint64_t guid, void *userdata) { + auto *b = static_cast(userdata); + Player wrappedPlayer(b->vtable, player, guid); + (*b->fn)(wrappedPlayer); + }, + binding.get()); + if (rc == 0) { + _playerEventSlots.push_back(std::move(slot)); + _playerEventBindings.push_back(std::move(binding)); + return true; + } + return false; + } + + struct PlayerEventBinding { + const FwHostVTable *vtable; + PlayerEventFn *fn; + }; + + FwHost *_host; + const FwHostVTable *_vtable; + std::vector> _commandSlots; + std::vector> _httpSlots; + std::vector> _playerEventSlots; + std::vector> _playerEventBindings; + }; + + /* --------------------------------------------------------------------- */ + /* Plugin base class */ + /* --------------------------------------------------------------------- */ + + class Base { + public: + virtual ~Base() = default; + + /* Return 0 on success, nonzero to abort plugin load. */ + virtual int OnInit(Host &host) = 0; + + virtual void OnPostScriptInit(Host & /*host*/) {} + virtual void OnUpdate(Host & /*host*/, double /*dt_seconds*/) {} + virtual void OnPreShutdown(Host & /*host*/) {} + virtual void OnShutdown(Host & /*host*/) {} + }; + +} // namespace Framework::Plugin + +/* --------------------------------------------------------------------------- */ +/* Required-export glue macro */ +/* --------------------------------------------------------------------------- */ + +/* + * Expand once per plugin, at file scope outside any namespace. Generates the + * required C exports and wires them to a singleton instance of PluginClass. + * + * FW_PLUGIN_DECLARE(MyPlugin, "my-plugin", "1.2.3") + */ +#define FW_PLUGIN_DECLARE(PluginClass, PluginName, PluginVersion) \ + namespace { \ + PluginClass g_fw_plugin_instance; \ + std::unique_ptr g_fw_plugin_host; \ + const FwPluginInfo g_fw_plugin_info_value = {PluginName, PluginVersion, FW_PLUGIN_ABI_VERSION}; \ + } \ + extern "C" FW_PLUGIN_EXPORT const FwPluginInfo *fw_plugin_info(void) { \ + return &g_fw_plugin_info_value; \ + } \ + extern "C" FW_PLUGIN_EXPORT int fw_plugin_init(FwHost *host) { \ + g_fw_plugin_host = std::make_unique(host); \ + if (!g_fw_plugin_host->Valid()) return 1; \ + return g_fw_plugin_instance.OnInit(*g_fw_plugin_host); \ + } \ + extern "C" FW_PLUGIN_EXPORT void fw_plugin_post_script_init(FwHost * /*host*/) { \ + if (g_fw_plugin_host) g_fw_plugin_instance.OnPostScriptInit(*g_fw_plugin_host); \ + } \ + extern "C" FW_PLUGIN_EXPORT void fw_plugin_update(FwHost * /*host*/, double dt) { \ + if (g_fw_plugin_host) g_fw_plugin_instance.OnUpdate(*g_fw_plugin_host, dt); \ + } \ + extern "C" FW_PLUGIN_EXPORT void fw_plugin_pre_shutdown(FwHost * /*host*/) { \ + if (g_fw_plugin_host) g_fw_plugin_instance.OnPreShutdown(*g_fw_plugin_host); \ + } \ + extern "C" FW_PLUGIN_EXPORT void fw_plugin_shutdown(FwHost * /*host*/) { \ + if (g_fw_plugin_host) { \ + g_fw_plugin_instance.OnShutdown(*g_fw_plugin_host); \ + g_fw_plugin_host.reset(); \ + } \ + } diff --git a/code/framework/src/integrations/server/plugins/sdk/fw_plugin_abi.h b/code/framework/src/integrations/server/plugins/sdk/fw_plugin_abi.h new file mode 100644 index 000000000..6aeab06cd --- /dev/null +++ b/code/framework/src/integrations/server/plugins/sdk/fw_plugin_abi.h @@ -0,0 +1,239 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +/* + * Framework Server Plugin SDK — C ABI (v1) + * =========================================================================== + * + * This header defines the binary contract between the Framework server and + * a native plugin shared library (.dll / .so) loaded at server startup. + * + * It is pure C and self-contained. Plugins may be written in C or C++. + * C++ plugin authors are encouraged to use the higher-level wrapper in + * fw_plugin.hpp, which sits entirely on top of this ABI. + * + * --------------------------------------------------------------------------- + * Manifest + * --------------------------------------------------------------------------- + * + * Each plugin ships alongside a JSON manifest (.module.json) declaring: + * + * { + * "name": "anticheat-vanilla", + * "version": "1.2.0", + * "abi_version": 1, + * "entry": "anticheat-vanilla", // platform suffix appended + * "depends_on": [ + * { "name": "telemetry-core", "version": ">=0.3.0" } + * ], + * "capabilities": ["http_endpoints", "commands"] + * } + * + * The server's `server.json` enumerates which plugins to load: + * + * { "modules": ["anticheat-vanilla", "telemetry-core"] } + * + * There is no filesystem auto-discovery. Plugins not listed are ignored. + * + * --------------------------------------------------------------------------- + * Required exports + * --------------------------------------------------------------------------- + * + * FW_PLUGIN_EXPORT const FwPluginInfo* fw_plugin_info(void); + * FW_PLUGIN_EXPORT int fw_plugin_init(FwHost* host); + * FW_PLUGIN_EXPORT void fw_plugin_shutdown(FwHost* host); + * + * Optional exports (host uses dlsym and treats missing as no-op): + * + * FW_PLUGIN_EXPORT void fw_plugin_post_script_init(FwHost* host); + * FW_PLUGIN_EXPORT void fw_plugin_update(FwHost* host, double dt_seconds); + * FW_PLUGIN_EXPORT void fw_plugin_pre_shutdown(FwHost* host); + * + * --------------------------------------------------------------------------- + * Lifecycle (mirrors Framework::Integrations::Server::Instance hooks) + * --------------------------------------------------------------------------- + * + * load host validates manifest + abi_version, dlopens the .so + * fw_plugin_info host reads name/version, verifies abi_version + * fw_plugin_init plugin registers commands/endpoints/callbacks via host + * fw_plugin_post_script_init plugin may register scripting builtins + * fw_plugin_update (each tick) plugin runs per-tick work, if any + * fw_plugin_pre_shutdown plugin flushes state, closes resources + * fw_plugin_shutdown plugin releases everything; host dlcloses + * + * Every host call into the plugin is wrapped in an exception fence by the + * loader. A throwing/crashing plugin is logged and disabled, not propagated. + * + * --------------------------------------------------------------------------- + * Memory ownership + * --------------------------------------------------------------------------- + * + * - All strings are null-terminated UTF-8 unless explicitly paired with a + * length argument. + * - Pointers handed from host to plugin are valid only for the duration of + * the call that received them, unless documented otherwise. + * - The plugin never returns heap memory to the host. To produce output + * (e.g. HTTP response bodies), the plugin writes through host-provided + * setter functions that own the allocation. + * + * This sidesteps allocator-mismatch UB across CRT boundaries. + * + * --------------------------------------------------------------------------- + * ABI compatibility + * --------------------------------------------------------------------------- + * + * - FW_PLUGIN_ABI_VERSION is bumped on any breaking change to the structs + * or callback signatures below. Plugin's FwPluginInfo.abi_version must + * match exactly. The host refuses to load otherwise. + * - The FwHostVTable is append-only across patch versions. New function + * pointers go at the end; older plugins ignore them. Removing or + * reordering vtable entries requires bumping FW_PLUGIN_ABI_VERSION. + * =========================================================================== + */ + +#ifndef FW_PLUGIN_ABI_H +#define FW_PLUGIN_ABI_H + +#include +#include + +#define FW_PLUGIN_ABI_VERSION 1 + +#if defined(_WIN32) +# define FW_PLUGIN_EXPORT __declspec(dllexport) +#else +# define FW_PLUGIN_EXPORT __attribute__((visibility("default"))) +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* ------------------------------------------------------------------------- */ +/* Opaque handle types */ +/* ------------------------------------------------------------------------- */ + +typedef struct FwHost FwHost; +typedef struct FwLogger FwLogger; +typedef struct FwPlayer FwPlayer; +typedef struct FwHttpResponse FwHttpResponse; + +/* ------------------------------------------------------------------------- */ +/* Plugin descriptor */ +/* ------------------------------------------------------------------------- */ + +typedef struct FwPluginInfo { + const char *name; /* matches manifest "name", e.g. "anticheat-vanilla" */ + const char *version; /* semver string, matches manifest "version" */ + uint32_t abi_version; /* must equal FW_PLUGIN_ABI_VERSION */ +} FwPluginInfo; + +/* ------------------------------------------------------------------------- */ +/* Callback signatures (host → plugin) */ +/* ------------------------------------------------------------------------- */ + +/* + * Command invocation. argv has argc entries, all null-terminated UTF-8. + * Strings remain valid only for the duration of the call. + */ +typedef void (*FwCommandCallback)(int argc, const char *const *argv, void *userdata); + +/* + * HTTP request handler. method/path/body strings remain valid for the call. + * The plugin writes the reply through response_set_* host functions. + */ +typedef void (*FwHttpCallback)(const char *method, + const char *path, + const char *body, + size_t body_len, + FwHttpResponse *response, + void *userdata); + +/* + * Player event. The FwPlayer pointer is valid for the duration of the call. + * The guid is also passed inline for convenience and lifetime-safe storage. + */ +typedef void (*FwPlayerEventCallback)(FwPlayer *player, uint64_t guid, void *userdata); + +/* ------------------------------------------------------------------------- */ +/* Host vtable */ +/* ------------------------------------------------------------------------- */ + +/* + * Append-only across patch versions. Adding new function pointers at the + * end is non-breaking for older plugins (which simply don't call them). + * Any reordering or removal requires bumping FW_PLUGIN_ABI_VERSION. + */ +typedef struct FwHostVTable { + uint32_t abi_version; /* set to FW_PLUGIN_ABI_VERSION by the host */ + + /* Logging --------------------------------------------------------------- */ + + /* Returns a logger scoped to the plugin name. Owned by host; do not free. */ + FwLogger *(*logger_for)(FwHost *host, const char *plugin_name); + + void (*log_debug)(FwLogger *logger, const char *message); + void (*log_info)(FwLogger *logger, const char *message); + void (*log_warn)(FwLogger *logger, const char *message); + void (*log_error)(FwLogger *logger, const char *message); + + /* Commands -------------------------------------------------------------- */ + + /* + * Registers a console command. Returns 0 on success, nonzero on failure + * (e.g. name already taken). Registration persists for plugin lifetime; + * commands are removed automatically when the plugin unloads. + */ + int (*register_command)(FwHost *host, const char *name, const char *description, FwCommandCallback callback, void *userdata); + + /* HTTP endpoints -------------------------------------------------------- */ + + /* + * Registers an HTTP endpoint on the webserver. Returns 0 on success. + * Path collisions with the Framework's built-in endpoints (e.g. "/") + * are rejected. + */ + int (*register_http_endpoint)(FwHost *host, const char *path, FwHttpCallback callback, void *userdata); + + void (*http_response_set_status)(FwHttpResponse *response, int status); + void (*http_response_set_body)(FwHttpResponse *response, const char *body, size_t body_len); + void (*http_response_set_header)(FwHttpResponse *response, const char *key, const char *value); + + /* Player events --------------------------------------------------------- */ + + int (*on_player_connect)(FwHost *host, FwPlayerEventCallback callback, void *userdata); + int (*on_player_disconnect)(FwHost *host, FwPlayerEventCallback callback, void *userdata); + + /* Player accessors ------------------------------------------------------ */ + + uint64_t (*player_get_guid)(FwPlayer *player); + + /* + * Copies the player's nickname into buf (null-terminated, truncated to + * fit). Returns the full length in bytes (excluding null terminator), + * so caller can detect truncation when (return >= buf_size). + */ + size_t (*player_get_nickname)(FwPlayer *player, char *buf, size_t buf_size); + + /* --- new entries appended below in future patch versions --- */ +} FwHostVTable; + +/* ------------------------------------------------------------------------- */ +/* Host instance handed to every plugin entry point */ +/* ------------------------------------------------------------------------- */ + +struct FwHost { + const FwHostVTable *vtable; + void *internal; /* opaque host implementation; plugin must not touch */ +}; + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif /* FW_PLUGIN_ABI_H */ From e72f7ad052327586647f862e068558f8133b917e Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Sun, 17 May 2026 21:54:08 +0200 Subject: [PATCH 2/5] Integrations: implement plugin loader, host vtable, and Instance wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the v1 plugin SDK to functional: native plugins listed in server.json are now dlopen'd at startup, given a host vtable backed by the existing webserver / command processor / world subsystems, and ticked through Server::Instance's lifecycle alongside the scripting engine. plugins/loader.{h,cpp} Cross-platform RAII SharedLibrary wrapper. LoadLibraryA on Windows, dlopen(RTLD_NOW|RTLD_LOCAL) on POSIX. PlatformFilename() resolves "hello-plugin" to hello-plugin.dll / libhello-plugin.so / libhello-plugin.dylib without the plugin author having to care. plugins/host_impl.{h,cpp} Concrete FwHostVTable backed by Framework subsystems: - logger_for / log_* → spdlog logger named "plugin:" - register_command → CommandProcessor with empty cxxopts; trampoline forwards unmatched positionals to the plugin as argv - register_http_endpoint → Webserver::RegisterRequest with an httplib::Response reinterpreted as opaque FwHttpResponse* - on_player_connect/disconnect → per-plugin slot lists, dispatched from Instance's existing player handlers - player_get_* → flecs entity lookup for the Streamer component (nickname, guid) Every plugin callback invocation is wrapped in try/catch; a throwing plugin logs and is contained, not propagated. PlayerHandle is a stack-local bridge so the opaque FwPlayer* never outlives the call. plugins/manager.{h,cpp} PluginManager owns the loaded plugin list. Reads //.module.json, validates that manifest abi_version and fw_plugin_info()->abi_version both match host, then invokes fw_plugin_init with a per-plugin FwHost. Lifecycle hooks (PostScriptInit / Update / PreShutdown / Shutdown) are fanned out with exception fences. Shutdown walks plugins in reverse load order and deregisters their commands before unmapping the library so trampoline pointers can't outlive their code. v1 limitations (documented as TODO in code): - depends_on is informational only; loads happen in server.json list order, missing deps log a warning - no hot reload - no capability enforcement (manifest field is recorded only) instance.{h,cpp} Owns a PluginManager. InstanceOptions gains modulesDir ("modules" by default) and modulesList, populated from server.json fields "modules_dir" and "modules". Wiring: Init → after PostInit, before scripting init PostScriptInit hook fires the matching plugin hook Update → ticks plugins every Instance tick with tickInterval as dt Shutdown → PreShutdown plugins early, then full Shutdown after the scripting engine teardown but before webserver close Player connect/disconnect handlers now dispatch to plugins in addition to the existing single-callback path CMakeLists.txt Adds the three .cpp files to FRAMEWORK_SERVER_SRC. Verified locally: loader.cpp passes -fsyntax-only standalone, sample hello-plugin still builds against the unchanged SDK headers and emits the six required fw_plugin_* exports. Full FrameworkServer build can't be verified on this macOS host due to a pre-existing libc++ / Xcode-CommandLineTools incompatibility in httplib.cc on develop (same failure mode reproduces on develop without this commit). --- code/framework/CMakeLists.txt | 5 + .../src/integrations/server/instance.cpp | 45 ++- .../src/integrations/server/instance.h | 13 + .../integrations/server/plugins/host_impl.cpp | 259 ++++++++++++++++ .../integrations/server/plugins/host_impl.h | 80 +++++ .../integrations/server/plugins/loader.cpp | 102 +++++++ .../src/integrations/server/plugins/loader.h | 61 ++++ .../integrations/server/plugins/manager.cpp | 277 ++++++++++++++++++ .../src/integrations/server/plugins/manager.h | 113 +++++++ 9 files changed, 952 insertions(+), 3 deletions(-) create mode 100644 code/framework/src/integrations/server/plugins/host_impl.cpp create mode 100644 code/framework/src/integrations/server/plugins/host_impl.h create mode 100644 code/framework/src/integrations/server/plugins/loader.cpp create mode 100644 code/framework/src/integrations/server/plugins/loader.h create mode 100644 code/framework/src/integrations/server/plugins/manager.cpp create mode 100644 code/framework/src/integrations/server/plugins/manager.h diff --git a/code/framework/CMakeLists.txt b/code/framework/CMakeLists.txt index a6de98537..bcb6dbfd3 100644 --- a/code/framework/CMakeLists.txt +++ b/code/framework/CMakeLists.txt @@ -57,6 +57,11 @@ set(FRAMEWORK_SERVER_SRC src/integrations/server/instance.cpp src/integrations/server/networking/engine.cpp + # Native plugin system (server-side) + src/integrations/server/plugins/loader.cpp + src/integrations/server/plugins/host_impl.cpp + src/integrations/server/plugins/manager.cpp + # JavaScript scripting (server module) src/integrations/server/scripting/module.cpp ) diff --git a/code/framework/src/integrations/server/instance.cpp b/code/framework/src/integrations/server/instance.cpp index 2b71fc198..7e0004e43 100644 --- a/code/framework/src/integrations/server/instance.cpp +++ b/code/framework/src/integrations/server/instance.cpp @@ -44,6 +44,7 @@ namespace Framework::Integrations::Server { _masterlist = std::make_unique(); _commandListener = std::make_unique(); _commandProcessor = std::make_unique(); + _pluginManager = std::make_unique(); } Instance::~Instance() { @@ -139,7 +140,14 @@ namespace Framework::Integrations::Server { // Initialize mod subsystems PostInit(); - + + // Native plugins load after PostInit so the mod has had a chance to + // register subsystems plugins may depend on, but before the scripting + // engine comes up so plugins can register scripting builtins from + // their PostScriptInit hook. + _pluginManager->Init(_webServer.get(), _commandProcessor.get(), _worldEngine.get()); + _pluginManager->LoadAll(_opts.modulesDir, _opts.modulesList); + const auto sdkCallback = [this](Framework::Scripting::Engine *engine) { this->RegisterScriptingBuiltins(engine); }; @@ -154,6 +162,7 @@ namespace Framework::Integrations::Server { CoreModules::SetScriptingModule(_scriptingModule.get()); PostScriptInit(); + _pluginManager->PostScriptInit(); // Discover resources _scriptingModule->GetResourceManager()->DiscoverResources(); @@ -231,6 +240,17 @@ namespace Framework::Integrations::Server { _opts.bindMapName = _fileConfig->Get("map"); _opts.maxPlayers = _fileConfig->Get("maxplayers"); _opts.bindSecretKey = _fileConfig->Get("server-token"); + + // Plugin loading: optional. modules_dir defaults to "modules" + // relative to the server's working directory; modules is the + // ordered list of plugin names to load. + _opts.modulesDir = _fileConfig->GetDefault("modules_dir", _opts.modulesDir); + if (auto *doc = _fileConfig->GetDocument(); doc && doc->contains("modules") && (*doc)["modules"].is_array()) { + _opts.modulesList.clear(); + for (const auto &m : (*doc)["modules"]) { + if (m.is_string()) _opts.modulesList.push_back(m.get()); + } + } } catch (const std::exception &ex) { Logging::GetLogger(FRAMEWORK_INNER_SERVER)->critical("JSON config has missing fields: {}", ex.what()); @@ -314,6 +334,9 @@ namespace Framework::Integrations::Server { if (_onPlayerDisconnectCallback) _onPlayerDisconnectCallback(e, guid.g); + if (_pluginManager) + _pluginManager->DispatchPlayerDisconnect(e.id(), guid.g); + _worldEngine->RemoveEntity(e); } @@ -345,8 +368,12 @@ namespace Framework::Integrations::Server { net->RegisterMessage(Framework::Networking::Messages::GameMessages::GAME_INIT_PLAYER, [this, net](SLNet::RakNetGUID guid, ClientInitPlayer *stub) { const auto e = _worldEngine->GetEntityByGUID(guid.g); - if (_onPlayerConnectCallback && e.is_valid() && e.is_alive()) - _onPlayerConnectCallback(e, guid.g); + if (e.is_valid() && e.is_alive()) { + if (_onPlayerConnectCallback) + _onPlayerConnectCallback(e, guid.g); + if (_pluginManager) + _pluginManager->DispatchPlayerConnect(e.id(), guid.g); + } }); // Note: Client-to-server events are handled through the JS Events system @@ -500,6 +527,10 @@ namespace Framework::Integrations::Server { PreShutdown(); + if (_pluginManager) { + _pluginManager->PreShutdown(); + } + if (_scriptingModule) { _scriptingModule->PreShutdown(); } @@ -512,6 +543,10 @@ namespace Framework::Integrations::Server { _scriptingModule->Shutdown(); } + if (_pluginManager) { + _pluginManager->Shutdown(); + } + if (_webServer) { _webServer->Shutdown(); } @@ -555,6 +590,10 @@ namespace Framework::Integrations::Server { _commandListener->Update(); } + if (_pluginManager) { + _pluginManager->Update(_opts.worldConfig.tickInterval); + } + if (_masterlist->IsInitialized()) { Services::ServerInfo info {}; info.port = _opts.bindPort; diff --git a/code/framework/src/integrations/server/instance.h b/code/framework/src/integrations/server/instance.h index eb747bb14..cbc1bf31a 100644 --- a/code/framework/src/integrations/server/instance.h +++ b/code/framework/src/integrations/server/instance.h @@ -14,6 +14,7 @@ #include "http/webserver.h" #include "logging/logger.h" #include "networking/engine.h" +#include "plugins/manager.h" #include "scripting/module.h" #include "services/masterlist.h" #include "utils/config.h" @@ -70,6 +71,13 @@ namespace Framework::Integrations::Server { int32_t maxPlayers; std::string httpServeDir; + // Native plugins. modulesDir is searched for each name in modulesList; + // a plugin called "foo" lives at /foo/foo.module.json plus + // its compiled library. Both fields are populated from server.json + // ("modules_dir" and "modules") and overridable at code level. + std::string modulesDir = "modules"; + std::vector modulesList; + bool enableSignals; // update intervals @@ -98,6 +106,7 @@ namespace Framework::Integrations::Server { std::unique_ptr _masterlist; std::unique_ptr _commandListener; std::unique_ptr _commandProcessor; + std::unique_ptr _pluginManager; void InitEndpoints(); void InitModules() const; @@ -181,6 +190,10 @@ namespace Framework::Integrations::Server { World::Archetypes::StreamingFactory *GetStreamingFactory() const { return _streamingFactory.get(); } + + Plugins::PluginManager *GetPluginManager() const { + return _pluginManager.get(); + } // Register a custom command with the command processor Utils::Result RegisterCommand(std::string_view name, std::initializer_list options, const Utils::CommandProc &proc, const std::string &desc) { diff --git a/code/framework/src/integrations/server/plugins/host_impl.cpp b/code/framework/src/integrations/server/plugins/host_impl.cpp new file mode 100644 index 000000000..f6b40b0af --- /dev/null +++ b/code/framework/src/integrations/server/plugins/host_impl.cpp @@ -0,0 +1,259 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#include "host_impl.h" + +#include "http/webserver.h" +#include "logging/logger.h" +#include "utils/command_processor.h" +#include "world/modules/base.hpp" +#include "world/server.h" + +#include +#include +#include + +namespace Framework::Integrations::Server::Plugins { + + /* + * Bridging between the C ABI's opaque FwPlayer* and the world's + * flecs::entity. The dispatcher fills a stack-local PlayerHandle and + * hands its address to plugin callbacks. The handle is valid only for + * the duration of the dispatched call. + */ + struct PlayerHandle { + HostImpl *host; + uint64_t entityId; + }; + + /* ----------------------------------------------------------------------- */ + /* Vtable function implementations */ + /* ----------------------------------------------------------------------- */ + + static FwLogger *VT_logger_for(FwHost *host, const char *plugin_name) { + auto *impl = static_cast(host->internal); + /* All plugin loggers share the same underlying spdlog instance we + * created at plugin load time; the plugin_name argument is honoured + * by the host when constructing the logger and ignored here. */ + (void)plugin_name; + return reinterpret_cast(impl->logger.get()); + } + + static void VT_log_debug(FwLogger *logger, const char *message) { + if (logger && message) reinterpret_cast(logger)->debug("{}", message); + } + static void VT_log_info(FwLogger *logger, const char *message) { + if (logger && message) reinterpret_cast(logger)->info("{}", message); + } + static void VT_log_warn(FwLogger *logger, const char *message) { + if (logger && message) reinterpret_cast(logger)->warn("{}", message); + } + static void VT_log_error(FwLogger *logger, const char *message) { + if (logger && message) reinterpret_cast(logger)->error("{}", message); + } + + static int VT_register_command(FwHost *host, const char *name, const char *description, FwCommandCallback callback, void *userdata) { + auto *impl = static_cast(host->internal); + if (!impl->commandProcessor || !name || !callback) return 1; + + std::string nameStr(name); + std::string descStr(description ? description : ""); + + /* The cxxopts::ParseResult passed in here is built from the original + * command-line tokens (including the command name as args[0]) with + * no options defined. result.unmatched() returns the positional + * tokens we hand straight to the plugin as argv. */ + auto proc = [callback, userdata, nameStr](cxxopts::ParseResult &result) { + std::vector args = result.unmatched(); + std::vector argv; + argv.reserve(args.size()); + for (auto &a : args) argv.push_back(a.c_str()); + try { + callback(static_cast(argv.size()), argv.empty() ? nullptr : argv.data(), userdata); + } + catch (const std::exception &e) { + Framework::Logging::GetLogger("plugins")->error("Plugin command '{}' threw: {}", nameStr, e.what()); + } + catch (...) { + Framework::Logging::GetLogger("plugins")->error("Plugin command '{}' threw non-std exception", nameStr); + } + }; + + auto result = impl->commandProcessor->RegisterCommand(nameStr, std::initializer_list {}, proc, descStr); + if (result.GetError() != Framework::Utils::CommandProcessorError::COMMAND_NONE) { + return static_cast(result.GetError()); + } + impl->registeredCommands.push_back(nameStr); + return 0; + } + + static int VT_register_http_endpoint(FwHost *host, const char *path, FwHttpCallback callback, void *userdata) { + auto *impl = static_cast(host->internal); + if (!impl->webserver || !path || !callback) return 1; + + std::string pathStr(path); + impl->webserver->RegisterRequest(pathStr, [callback, userdata, pathStr](const httplib::Request &req, httplib::Response &res) { + try { + callback(req.method.c_str(), req.path.c_str(), req.body.data(), req.body.size(), reinterpret_cast(&res), userdata); + } + catch (const std::exception &e) { + Framework::Logging::GetLogger("plugins")->error("Plugin HTTP handler '{}' threw: {}", pathStr, e.what()); + res.status = 500; + } + catch (...) { + Framework::Logging::GetLogger("plugins")->error("Plugin HTTP handler '{}' threw non-std exception", pathStr); + res.status = 500; + } + }); + return 0; + } + + static void VT_http_response_set_status(FwHttpResponse *response, int status) { + if (response) reinterpret_cast(response)->status = status; + } + + static void VT_http_response_set_body(FwHttpResponse *response, const char *body, size_t body_len) { + if (response && body) reinterpret_cast(response)->body.assign(body, body_len); + } + + static void VT_http_response_set_header(FwHttpResponse *response, const char *key, const char *value) { + if (response && key && value) reinterpret_cast(response)->set_header(key, value); + } + + static int VT_on_player_connect(FwHost *host, FwPlayerEventCallback callback, void *userdata) { + auto *impl = static_cast(host->internal); + if (!callback) return 1; + auto slot = std::make_unique(); + slot->fn = callback; + slot->userdata = userdata; + std::lock_guard lock(impl->slotMutex); + impl->onConnect.push_back(std::move(slot)); + return 0; + } + + static int VT_on_player_disconnect(FwHost *host, FwPlayerEventCallback callback, void *userdata) { + auto *impl = static_cast(host->internal); + if (!callback) return 1; + auto slot = std::make_unique(); + slot->fn = callback; + slot->userdata = userdata; + std::lock_guard lock(impl->slotMutex); + impl->onDisconnect.push_back(std::move(slot)); + return 0; + } + + static uint64_t VT_player_get_guid(FwPlayer *player) { + if (!player) return 0; + auto *handle = reinterpret_cast(player); + if (!handle->host || !handle->host->worldEngine) return 0; + auto world = handle->host->worldEngine->GetWorld(); + if (!world) return 0; + flecs::entity e(*world, handle->entityId); + if (!e.is_valid()) return 0; + const auto *streamer = e.get(); + return streamer ? streamer->guid : 0; + } + + static size_t VT_player_get_nickname(FwPlayer *player, char *buf, size_t buf_size) { + if (!player) return 0; + auto *handle = reinterpret_cast(player); + if (!handle->host || !handle->host->worldEngine) return 0; + auto world = handle->host->worldEngine->GetWorld(); + if (!world) return 0; + flecs::entity e(*world, handle->entityId); + if (!e.is_valid()) return 0; + const auto *streamer = e.get(); + if (!streamer) return 0; + const std::string &nick = streamer->nickname; + if (buf && buf_size > 0) { + const size_t toCopy = std::min(nick.size(), buf_size - 1); + std::memcpy(buf, nick.data(), toCopy); + buf[toCopy] = '\0'; + } + return nick.size(); + } + + /* ----------------------------------------------------------------------- */ + /* Public surface */ + /* ----------------------------------------------------------------------- */ + + const FwHostVTable *GetHostVTable() { + static const FwHostVTable vt = { + /* abi_version */ FW_PLUGIN_ABI_VERSION, + /* logger_for */ &VT_logger_for, + /* log_debug */ &VT_log_debug, + /* log_info */ &VT_log_info, + /* log_warn */ &VT_log_warn, + /* log_error */ &VT_log_error, + /* register_command */ &VT_register_command, + /* register_http_endpoint */ &VT_register_http_endpoint, + /* http_response_set_status */ &VT_http_response_set_status, + /* http_response_set_body */ &VT_http_response_set_body, + /* http_response_set_header */ &VT_http_response_set_header, + /* on_player_connect */ &VT_on_player_connect, + /* on_player_disconnect */ &VT_on_player_disconnect, + /* player_get_guid */ &VT_player_get_guid, + /* player_get_nickname */ &VT_player_get_nickname, + }; + return &vt; + } + + FwHost MakeFwHost(HostImpl *impl) { + FwHost host {}; + host.vtable = GetHostVTable(); + host.internal = impl; + return host; + } + + void DispatchPlayerConnect(HostImpl *impl, uint64_t entityId, uint64_t guid) { + if (!impl) return; + PlayerHandle handle {impl, entityId}; + /* Copy the slot list under the lock so callbacks can register more + * slots without us iterating a mutating vector. */ + std::vector snapshot; + { + std::lock_guard lock(impl->slotMutex); + snapshot.reserve(impl->onConnect.size()); + for (auto &slot : impl->onConnect) snapshot.push_back(*slot); + } + for (auto &slot : snapshot) { + try { + slot.fn(reinterpret_cast(&handle), guid, slot.userdata); + } + catch (const std::exception &e) { + Framework::Logging::GetLogger("plugins")->error("Plugin '{}' onConnect threw: {}", impl->pluginName, e.what()); + } + catch (...) { + Framework::Logging::GetLogger("plugins")->error("Plugin '{}' onConnect threw non-std exception", impl->pluginName); + } + } + } + + void DispatchPlayerDisconnect(HostImpl *impl, uint64_t entityId, uint64_t guid) { + if (!impl) return; + PlayerHandle handle {impl, entityId}; + std::vector snapshot; + { + std::lock_guard lock(impl->slotMutex); + snapshot.reserve(impl->onDisconnect.size()); + for (auto &slot : impl->onDisconnect) snapshot.push_back(*slot); + } + for (auto &slot : snapshot) { + try { + slot.fn(reinterpret_cast(&handle), guid, slot.userdata); + } + catch (const std::exception &e) { + Framework::Logging::GetLogger("plugins")->error("Plugin '{}' onDisconnect threw: {}", impl->pluginName, e.what()); + } + catch (...) { + Framework::Logging::GetLogger("plugins")->error("Plugin '{}' onDisconnect threw non-std exception", impl->pluginName); + } + } + } + +} // namespace Framework::Integrations::Server::Plugins diff --git a/code/framework/src/integrations/server/plugins/host_impl.h b/code/framework/src/integrations/server/plugins/host_impl.h new file mode 100644 index 000000000..acd8861f5 --- /dev/null +++ b/code/framework/src/integrations/server/plugins/host_impl.h @@ -0,0 +1,80 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include "sdk/fw_plugin_abi.h" + +#include +#include +#include +#include +#include +#include + +namespace Framework { + namespace HTTP { + class Webserver; + } + namespace Utils { + class CommandProcessor; + } + namespace World { + class ServerEngine; + } +} // namespace Framework + +namespace Framework::Integrations::Server::Plugins { + + /* + * Concrete backing for FwHost::internal. One instance per loaded plugin + * so callback userdata lifetimes match the plugin's own lifetime, and + * we can tear everything down at unload without leaking handles. + * + * Stores raw subsystem pointers — never owns. The owning Server::Instance + * outlives every plugin (plugins unload during Instance::Shutdown). + */ + struct HostImpl { + std::string pluginName; + Framework::HTTP::Webserver *webserver = nullptr; + Framework::Utils::CommandProcessor *commandProcessor = nullptr; + Framework::World::ServerEngine *worldEngine = nullptr; + + std::shared_ptr logger; /* scoped: "plugin:" */ + + /* Names of commands the plugin registered, so we can deregister + * cleanly when the plugin unloads. */ + std::vector registeredCommands; + + /* Per-event callback slots. Pointers are stable across vector growth + * because we store unique_ptrs. */ + struct PlayerEventSlot { + FwPlayerEventCallback fn; + void *userdata; + }; + std::vector> onConnect; + std::vector> onDisconnect; + + std::mutex slotMutex; /* guards onConnect/onDisconnect for safe iteration */ + }; + + /* Returns the shared, statically-initialised host vtable. Pointer is + * valid for the lifetime of the process and identical across plugins. */ + const FwHostVTable *GetHostVTable(); + + /* Construct a FwHost owning the given HostImpl. The caller owns the impl; + * the returned FwHost references it by raw pointer. */ + FwHost MakeFwHost(HostImpl *impl); + + /* Invoke every registered onConnect callback for the given entity/guid. + * Exceptions thrown by plugin callbacks are caught and logged; one + * misbehaving plugin does not prevent others from running. */ + void DispatchPlayerConnect(HostImpl *impl, uint64_t entityId, uint64_t guid); + void DispatchPlayerDisconnect(HostImpl *impl, uint64_t entityId, uint64_t guid); + +} // namespace Framework::Integrations::Server::Plugins diff --git a/code/framework/src/integrations/server/plugins/loader.cpp b/code/framework/src/integrations/server/plugins/loader.cpp new file mode 100644 index 000000000..45b04a20a --- /dev/null +++ b/code/framework/src/integrations/server/plugins/loader.cpp @@ -0,0 +1,102 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#include "loader.h" + +#if defined(_WIN32) +# include +#else +# include +#endif + +namespace Framework::Integrations::Server::Plugins { + +#if defined(_WIN32) + static std::string FormatWin32Error(DWORD code) { + if (code == 0) return {}; + LPSTR buf = nullptr; + DWORD size = FormatMessageA(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, code, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), reinterpret_cast(&buf), 0, nullptr); + std::string msg(buf, size); + LocalFree(buf); + while (!msg.empty() && (msg.back() == '\n' || msg.back() == '\r')) msg.pop_back(); + return msg; + } +#endif + + SharedLibrary::~SharedLibrary() { + Close(); + } + + SharedLibrary::SharedLibrary(SharedLibrary &&other) noexcept: _handle(other._handle), _path(std::move(other._path)), _lastError(std::move(other._lastError)) { + other._handle = nullptr; + } + + SharedLibrary &SharedLibrary::operator=(SharedLibrary &&other) noexcept { + if (this != &other) { + Close(); + _handle = other._handle; + _path = std::move(other._path); + _lastError = std::move(other._lastError); + other._handle = nullptr; + } + return *this; + } + + void SharedLibrary::Close() { + if (!_handle) return; +#if defined(_WIN32) + FreeLibrary(static_cast(_handle)); +#else + dlclose(_handle); +#endif + _handle = nullptr; + } + + bool SharedLibrary::Open(const std::string &path) { + Close(); + _path = path; + _lastError.clear(); +#if defined(_WIN32) + _handle = LoadLibraryA(path.c_str()); + if (!_handle) { + _lastError = FormatWin32Error(GetLastError()); + return false; + } +#else + _handle = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL); + if (!_handle) { + const char *err = dlerror(); + _lastError = err ? err : "unknown dlopen error"; + return false; + } +#endif + return true; + } + + void *SharedLibrary::Symbol(const char *name) const { + if (!_handle) return nullptr; +#if defined(_WIN32) + return reinterpret_cast(GetProcAddress(static_cast(_handle), name)); +#else + /* Clear any prior error so the nullptr-vs-actually-null check works */ + dlerror(); + return dlsym(_handle, name); +#endif + } + + std::string SharedLibrary::PlatformFilename(const std::string &entry) { +#if defined(_WIN32) + return entry + ".dll"; +#elif defined(__APPLE__) + return "lib" + entry + ".dylib"; +#else + return "lib" + entry + ".so"; +#endif + } + +} // namespace Framework::Integrations::Server::Plugins diff --git a/code/framework/src/integrations/server/plugins/loader.h b/code/framework/src/integrations/server/plugins/loader.h new file mode 100644 index 000000000..df12b4f9a --- /dev/null +++ b/code/framework/src/integrations/server/plugins/loader.h @@ -0,0 +1,61 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include + +namespace Framework::Integrations::Server::Plugins { + + /* + * RAII wrapper around platform-specific shared-library loading. + * Move-only; closes the handle on destruction. + */ + class SharedLibrary final { + public: + SharedLibrary() = default; + ~SharedLibrary(); + + SharedLibrary(const SharedLibrary &) = delete; + SharedLibrary &operator=(const SharedLibrary &) = delete; + + SharedLibrary(SharedLibrary &&other) noexcept; + SharedLibrary &operator=(SharedLibrary &&other) noexcept; + + /* Open the library at the given absolute path. Returns true on success; + * on failure, GetLastError() describes the platform error. */ + bool Open(const std::string &path); + + /* Returns the address of an exported symbol or nullptr if missing. */ + void *Symbol(const char *name) const; + + bool IsOpen() const { + return _handle != nullptr; + } + + const std::string &GetPath() const { + return _path; + } + const std::string &GetLastError() const { + return _lastError; + } + + /* Returns the platform-appropriate filename for a plugin "entry" stem + * (e.g. "hello-plugin" -> "hello-plugin.dll" on Windows, + * "libhello-plugin.so" on Linux, "libhello-plugin.dylib" on macOS). */ + static std::string PlatformFilename(const std::string &entry); + + private: + void Close(); + + void *_handle = nullptr; + std::string _path; + std::string _lastError; + }; + +} // namespace Framework::Integrations::Server::Plugins diff --git a/code/framework/src/integrations/server/plugins/manager.cpp b/code/framework/src/integrations/server/plugins/manager.cpp new file mode 100644 index 000000000..83bcdb464 --- /dev/null +++ b/code/framework/src/integrations/server/plugins/manager.cpp @@ -0,0 +1,277 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#include "manager.h" + +#include "logging/logger.h" +#include "utils/command_processor.h" + +#include +#include +#include +#include +#include +#include + +namespace Framework::Integrations::Server::Plugins { + + static constexpr const char *kLoggerName = "plugins"; + + PluginManager::PluginManager() = default; + + PluginManager::~PluginManager() { + if (!_shutdownCalled) Shutdown(); + } + + void PluginManager::Init(Framework::HTTP::Webserver *webserver, Framework::Utils::CommandProcessor *commandProcessor, Framework::World::ServerEngine *worldEngine) { + _webserver = webserver; + _commandProcessor = commandProcessor; + _worldEngine = worldEngine; + } + + size_t PluginManager::LoadAll(const std::string &modulesDir, const std::vector &pluginNames) { + auto log = Framework::Logging::GetLogger(kLoggerName); + if (pluginNames.empty()) { + log->debug("No plugins listed in server config; skipping load"); + return 0; + } + log->info("Loading {} plugin(s) from '{}'", pluginNames.size(), modulesDir); + size_t loaded = 0; + for (const auto &name : pluginNames) { + if (LoadOne(modulesDir, name)) ++loaded; + } + log->info("Plugin load complete: {}/{} succeeded", loaded, pluginNames.size()); + return loaded; + } + + bool PluginManager::LoadOne(const std::string &modulesDir, const std::string &name) { + auto log = Framework::Logging::GetLogger(kLoggerName); + + const std::filesystem::path baseDir = std::filesystem::path(modulesDir) / name; + const std::filesystem::path manifestPath = baseDir / (name + ".module.json"); + + if (!std::filesystem::exists(manifestPath)) { + log->error("Plugin '{}' missing manifest at {}", name, manifestPath.string()); + return false; + } + + nlohmann::json manifest; + try { + std::ifstream in(manifestPath); + std::stringstream buf; + buf << in.rdbuf(); + manifest = nlohmann::json::parse(buf.str()); + } + catch (const std::exception &e) { + log->error("Plugin '{}' manifest parse error: {}", name, e.what()); + return false; + } + + const std::string declaredName = manifest.value("name", ""); + if (declaredName != name) { + log->error("Plugin '{}' manifest name mismatch (got '{}')", name, declaredName); + return false; + } + + const uint32_t manifestAbi = manifest.value("abi_version", 0u); + if (manifestAbi != FW_PLUGIN_ABI_VERSION) { + log->error("Plugin '{}' manifest abi_version {} != host {}", name, manifestAbi, FW_PLUGIN_ABI_VERSION); + return false; + } + + const std::string entry = manifest.value("entry", name); + const auto libPath = baseDir / SharedLibrary::PlatformFilename(entry); + + auto plugin = std::make_unique(); + plugin->name = name; + plugin->version = manifest.value("version", ""); + + if (!plugin->library.Open(libPath.string())) { + log->error("Plugin '{}' dlopen failed for {}: {}", name, libPath.string(), plugin->library.GetLastError()); + return false; + } + + plugin->infoFn = reinterpret_cast(plugin->library.Symbol("fw_plugin_info")); + plugin->initFn = reinterpret_cast(plugin->library.Symbol("fw_plugin_init")); + plugin->shutdownFn = reinterpret_cast(plugin->library.Symbol("fw_plugin_shutdown")); + plugin->postScriptInitFn = reinterpret_cast(plugin->library.Symbol("fw_plugin_post_script_init")); + plugin->updateFn = reinterpret_cast(plugin->library.Symbol("fw_plugin_update")); + plugin->preShutdownFn = reinterpret_cast(plugin->library.Symbol("fw_plugin_pre_shutdown")); + + if (!plugin->infoFn || !plugin->initFn || !plugin->shutdownFn) { + log->error("Plugin '{}' missing required exports (fw_plugin_info / fw_plugin_init / fw_plugin_shutdown)", name); + return false; + } + + const FwPluginInfo *info = nullptr; + try { + info = plugin->infoFn(); + } + catch (...) { + log->error("Plugin '{}' fw_plugin_info threw", name); + return false; + } + if (!info || info->abi_version != FW_PLUGIN_ABI_VERSION) { + log->error("Plugin '{}' reported abi_version {} != host {}", name, info ? info->abi_version : 0u, FW_PLUGIN_ABI_VERSION); + return false; + } + if (info->name && declaredName != info->name) { + log->warn("Plugin '{}' info->name='{}' disagrees with manifest", name, info->name); + } + if (info->version && plugin->version != info->version) { + log->warn("Plugin '{}' info->version='{}' disagrees with manifest '{}'", name, info->version, plugin->version); + } + + plugin->impl = std::make_unique(); + plugin->impl->pluginName = name; + plugin->impl->webserver = _webserver; + plugin->impl->commandProcessor = _commandProcessor; + plugin->impl->worldEngine = _worldEngine; + plugin->impl->logger = Framework::Logging::GetLogger((std::string("plugin:") + name).c_str()); + plugin->host = MakeFwHost(plugin->impl.get()); + + int initRc = 0; + try { + initRc = plugin->initFn(&plugin->host); + } + catch (const std::exception &e) { + log->error("Plugin '{}' fw_plugin_init threw: {}", name, e.what()); + return false; + } + catch (...) { + log->error("Plugin '{}' fw_plugin_init threw non-std exception", name); + return false; + } + if (initRc != 0) { + log->error("Plugin '{}' fw_plugin_init returned {}", name, initRc); + return false; + } + + /* Informational: warn about unsatisfied dependencies. v1 does not + * reorder loads, but flags missing deps so authors notice. */ + if (manifest.contains("depends_on") && manifest["depends_on"].is_array()) { + for (const auto &dep : manifest["depends_on"]) { + const std::string depName = dep.value("name", ""); + bool found = false; + for (const auto &p : _plugins) { + if (p->name == depName) { + found = true; + break; + } + } + if (!found) { + log->warn("Plugin '{}' declares dependency on '{}' which is not loaded (yet)", name, depName); + } + } + } + + plugin->initSucceeded = true; + log->info("Loaded plugin '{}' v{}", name, plugin->version); + _plugins.push_back(std::move(plugin)); + return true; + } + + /* ----------------------------------------------------------------------- */ + /* Lifecycle dispatch */ + /* ----------------------------------------------------------------------- */ + + void PluginManager::PostScriptInit() { + for (auto &p : _plugins) { + if (!p->initSucceeded || !p->postScriptInitFn) continue; + try { + p->postScriptInitFn(&p->host); + } + catch (const std::exception &e) { + Framework::Logging::GetLogger(kLoggerName)->error("Plugin '{}' fw_plugin_post_script_init threw: {}", p->name, e.what()); + } + catch (...) { + Framework::Logging::GetLogger(kLoggerName)->error("Plugin '{}' fw_plugin_post_script_init threw non-std exception", p->name); + } + } + } + + void PluginManager::Update(double dtSeconds) { + for (auto &p : _plugins) { + if (!p->initSucceeded || !p->updateFn) continue; + try { + p->updateFn(&p->host, dtSeconds); + } + catch (const std::exception &e) { + Framework::Logging::GetLogger(kLoggerName)->error("Plugin '{}' fw_plugin_update threw: {}", p->name, e.what()); + p->initSucceeded = false; /* disable to stop the spam */ + } + catch (...) { + Framework::Logging::GetLogger(kLoggerName)->error("Plugin '{}' fw_plugin_update threw non-std exception", p->name); + p->initSucceeded = false; + } + } + } + + void PluginManager::PreShutdown() { + for (auto &p : _plugins) { + if (!p->initSucceeded || !p->preShutdownFn) continue; + try { + p->preShutdownFn(&p->host); + } + catch (const std::exception &e) { + Framework::Logging::GetLogger(kLoggerName)->error("Plugin '{}' fw_plugin_pre_shutdown threw: {}", p->name, e.what()); + } + catch (...) { + Framework::Logging::GetLogger(kLoggerName)->error("Plugin '{}' fw_plugin_pre_shutdown threw non-std exception", p->name); + } + } + } + + void PluginManager::Shutdown() { + if (_shutdownCalled) return; + _shutdownCalled = true; + + /* Reverse order: last-loaded plugin shuts down first, so a plugin + * built on top of another's services can clean up before that + * service goes away. */ + for (auto it = _plugins.rbegin(); it != _plugins.rend(); ++it) { + auto &p = *it; + if (p->initSucceeded && p->shutdownFn) { + try { + p->shutdownFn(&p->host); + } + catch (const std::exception &e) { + Framework::Logging::GetLogger(kLoggerName)->error("Plugin '{}' fw_plugin_shutdown threw: {}", p->name, e.what()); + } + catch (...) { + Framework::Logging::GetLogger(kLoggerName)->error("Plugin '{}' fw_plugin_shutdown threw non-std exception", p->name); + } + } + /* Tear down host side bindings (commands etc.) before the + * library is unmapped, so trampoline pointers can't be invoked + * against freed code. */ + if (p->impl && _commandProcessor) { + for (const auto &cmd : p->impl->registeredCommands) { + _commandProcessor->RemoveCommand(cmd); + } + } + } + + _plugins.clear(); + } + + void PluginManager::DispatchPlayerConnect(uint64_t entityId, uint64_t guid) { + for (auto &p : _plugins) { + if (!p->initSucceeded) continue; + Framework::Integrations::Server::Plugins::DispatchPlayerConnect(p->impl.get(), entityId, guid); + } + } + + void PluginManager::DispatchPlayerDisconnect(uint64_t entityId, uint64_t guid) { + for (auto &p : _plugins) { + if (!p->initSucceeded) continue; + Framework::Integrations::Server::Plugins::DispatchPlayerDisconnect(p->impl.get(), entityId, guid); + } + } + +} // namespace Framework::Integrations::Server::Plugins diff --git a/code/framework/src/integrations/server/plugins/manager.h b/code/framework/src/integrations/server/plugins/manager.h new file mode 100644 index 000000000..f6f6bd8c2 --- /dev/null +++ b/code/framework/src/integrations/server/plugins/manager.h @@ -0,0 +1,113 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include "host_impl.h" +#include "loader.h" +#include "sdk/fw_plugin_abi.h" + +#include +#include +#include + +namespace Framework { + namespace HTTP { + class Webserver; + } + namespace Utils { + class CommandProcessor; + } + namespace World { + class ServerEngine; + } +} // namespace Framework + +namespace Framework::Integrations::Server::Plugins { + + /* + * Resolved plugin entry points, kept alongside the loaded library so + * the function pointers stay valid for the plugin's lifetime. + */ + struct LoadedPlugin { + std::string name; + std::string version; + SharedLibrary library; + std::unique_ptr impl; + FwHost host {}; + + const FwPluginInfo *(*infoFn)(void) = nullptr; + int (*initFn)(FwHost *) = nullptr; + void (*postScriptInitFn)(FwHost *) = nullptr; + void (*updateFn)(FwHost *, double) = nullptr; + void (*preShutdownFn)(FwHost *) = nullptr; + void (*shutdownFn)(FwHost *) = nullptr; + + bool initSucceeded = false; + }; + + /* + * Loads, ticks, and unloads native plugins listed in server.json. + * + * Lifecycle (called from Server::Instance): + * 1. Init(webserver, commands, world) — wire subsystem pointers + * 2. LoadAll(modulesDir, names) — find manifests, dlopen, + * validate ABI, run fw_plugin_init + * 3. PostScriptInit() — after scripting engine is up + * 4. Update(dt) every tick — fw_plugin_update on each + * 5. PreShutdown() — fw_plugin_pre_shutdown + * 6. Shutdown() — fw_plugin_shutdown + unload + * + * The manager guarantees that exceptions thrown out of any plugin entry + * point are caught and logged. A plugin that fails to load or that + * throws during a lifecycle hook is disabled, not propagated. + */ + class PluginManager final { + public: + PluginManager(); + ~PluginManager(); + + void Init(Framework::HTTP::Webserver *webserver, Framework::Utils::CommandProcessor *commandProcessor, Framework::World::ServerEngine *worldEngine); + + /* + * Load each plugin in `pluginNames`, in order. For each name, looks + * up //.module.json. Plugins that fail to + * load are logged and skipped; load failures are not fatal. + * Returns the number of plugins successfully loaded. + */ + size_t LoadAll(const std::string &modulesDir, const std::vector &pluginNames); + + void PostScriptInit(); + void Update(double dtSeconds); + void PreShutdown(); + void Shutdown(); + + /* + * Dispatch points wired from Instance's existing player connect / + * disconnect callbacks. entityId is the flecs entity id; guid is + * the network GUID. + */ + void DispatchPlayerConnect(uint64_t entityId, uint64_t guid); + void DispatchPlayerDisconnect(uint64_t entityId, uint64_t guid); + + size_t Count() const { + return _plugins.size(); + } + + private: + bool LoadOne(const std::string &modulesDir, const std::string &name); + + Framework::HTTP::Webserver *_webserver = nullptr; + Framework::Utils::CommandProcessor *_commandProcessor = nullptr; + Framework::World::ServerEngine *_worldEngine = nullptr; + + std::vector> _plugins; + bool _shutdownCalled = false; + }; + +} // namespace Framework::Integrations::Server::Plugins From f526ade186cdd45af7ff0dda578d2cb7f929d55e Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Mon, 18 May 2026 22:33:59 +0200 Subject: [PATCH 3/5] Plugins: address self-review findings Bug fixes - Shutdown order: unload plugin libraries AFTER the webserver thread stops, not before. Previously a plugin HTTP trampoline could fire on freed code if a request was in flight during teardown. - Command argv[0]: re-prepend the command name in the register_command trampoline. cxxopts excludes the program token from unmatched(), so plugins were seeing args shifted by one with no contract on what argv[0] was. - logger_for(host, plugin_name) now honours plugin_name. Sub-loggers are created on demand under a "plugin:" prefix (so a plugin can't impersonate FRAMEWORK_INNER_SERVER etc.) and cached on HostImpl so the raw spdlog::logger* handed to the plugin is stable for the plugin's lifetime. Smell fixes - PlayerHandle now carries the guid directly; player_get_guid is a one-line accessor instead of a full flecs entity + Streamer lookup for data the dispatcher already had. - Wrapper's RegisterHttpEndpoint no longer pushes the binding into _httpBindings until the host call returns success. The defunct MakeHttpBinding helper is gone. - HTTP trampoline catch arms clear res.body before setting status=500 so the client doesn't get a 500 with a half-written body. Polish - server.json "modules" field present-but-not-array now warns instead of silently ignoring. - ABI memory-ownership doc carves out FwLogger* and FwHost* as plugin-lifetime, while keeping FwPlayer* and FwHttpResponse* as strictly call-scoped. Verified: hello-plugin sample still builds clean against the updated wrapper and emits the six required fw_plugin_* exports. --- .../src/integrations/server/instance.cpp | 24 ++++++--- .../integrations/server/plugins/host_impl.cpp | 53 +++++++++++-------- .../integrations/server/plugins/host_impl.h | 7 +++ .../server/plugins/sdk/fw_plugin.hpp | 27 +++++----- .../server/plugins/sdk/fw_plugin_abi.h | 7 ++- 5 files changed, 73 insertions(+), 45 deletions(-) diff --git a/code/framework/src/integrations/server/instance.cpp b/code/framework/src/integrations/server/instance.cpp index 7e0004e43..20faaf3a4 100644 --- a/code/framework/src/integrations/server/instance.cpp +++ b/code/framework/src/integrations/server/instance.cpp @@ -245,10 +245,15 @@ namespace Framework::Integrations::Server { // relative to the server's working directory; modules is the // ordered list of plugin names to load. _opts.modulesDir = _fileConfig->GetDefault("modules_dir", _opts.modulesDir); - if (auto *doc = _fileConfig->GetDocument(); doc && doc->contains("modules") && (*doc)["modules"].is_array()) { - _opts.modulesList.clear(); - for (const auto &m : (*doc)["modules"]) { - if (m.is_string()) _opts.modulesList.push_back(m.get()); + if (auto *doc = _fileConfig->GetDocument(); doc && doc->contains("modules")) { + if ((*doc)["modules"].is_array()) { + _opts.modulesList.clear(); + for (const auto &m : (*doc)["modules"]) { + if (m.is_string()) _opts.modulesList.push_back(m.get()); + } + } + else { + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->warn("server.json 'modules' field exists but is not an array; ignoring"); } } } @@ -543,14 +548,17 @@ namespace Framework::Integrations::Server { _scriptingModule->Shutdown(); } - if (_pluginManager) { - _pluginManager->Shutdown(); - } - if (_webServer) { _webServer->Shutdown(); } + // Plugin libraries are unloaded last so any in-flight HTTP requests + // handled by plugin trampolines have already finished with the + // webserver stopped above. + if (_pluginManager) { + _pluginManager->Shutdown(); + } + if (_worldEngine) { _worldEngine->Shutdown(); } diff --git a/code/framework/src/integrations/server/plugins/host_impl.cpp b/code/framework/src/integrations/server/plugins/host_impl.cpp index f6b40b0af..3f5eb6fb3 100644 --- a/code/framework/src/integrations/server/plugins/host_impl.cpp +++ b/code/framework/src/integrations/server/plugins/host_impl.cpp @@ -29,6 +29,7 @@ namespace Framework::Integrations::Server::Plugins { struct PlayerHandle { HostImpl *host; uint64_t entityId; + uint64_t guid; }; /* ----------------------------------------------------------------------- */ @@ -37,11 +38,24 @@ namespace Framework::Integrations::Server::Plugins { static FwLogger *VT_logger_for(FwHost *host, const char *plugin_name) { auto *impl = static_cast(host->internal); - /* All plugin loggers share the same underlying spdlog instance we - * created at plugin load time; the plugin_name argument is honoured - * by the host when constructing the logger and ignored here. */ - (void)plugin_name; - return reinterpret_cast(impl->logger.get()); + /* No name (or empty) → return the plugin's default logger. */ + if (!plugin_name || !*plugin_name) { + return reinterpret_cast(impl->logger.get()); + } + + /* Namespace every plugin-requested logger under "plugin:" so a + * plugin can't impersonate a Framework-internal logger by passing + * names like FRAMEWORK_INNER_SERVER. */ + std::string fullName = std::string("plugin:") + plugin_name; + + std::lock_guard lock(impl->loggerCacheMutex); + auto it = impl->loggerCache.find(fullName); + if (it != impl->loggerCache.end()) { + return reinterpret_cast(it->second.get()); + } + auto sub = Framework::Logging::GetLogger(fullName.c_str()); + auto [inserted, _] = impl->loggerCache.emplace(fullName, std::move(sub)); + return reinterpret_cast(inserted->second.get()); } static void VT_log_debug(FwLogger *logger, const char *message) { @@ -64,17 +78,17 @@ namespace Framework::Integrations::Server::Plugins { std::string nameStr(name); std::string descStr(description ? description : ""); - /* The cxxopts::ParseResult passed in here is built from the original - * command-line tokens (including the command name as args[0]) with - * no options defined. result.unmatched() returns the positional - * tokens we hand straight to the plugin as argv. */ + /* cxxopts treats the first parsed token as the program name and + * excludes it from unmatched(); we re-prepend the command name so + * the plugin sees the conventional argv[0]==command shape. */ auto proc = [callback, userdata, nameStr](cxxopts::ParseResult &result) { std::vector args = result.unmatched(); std::vector argv; - argv.reserve(args.size()); + argv.reserve(args.size() + 1); + argv.push_back(nameStr.c_str()); for (auto &a : args) argv.push_back(a.c_str()); try { - callback(static_cast(argv.size()), argv.empty() ? nullptr : argv.data(), userdata); + callback(static_cast(argv.size()), argv.data(), userdata); } catch (const std::exception &e) { Framework::Logging::GetLogger("plugins")->error("Plugin command '{}' threw: {}", nameStr, e.what()); @@ -103,10 +117,14 @@ namespace Framework::Integrations::Server::Plugins { } catch (const std::exception &e) { Framework::Logging::GetLogger("plugins")->error("Plugin HTTP handler '{}' threw: {}", pathStr, e.what()); + /* Drop whatever the handler partially wrote so the client + * doesn't get a 500 with a half-built body. */ + res.body.clear(); res.status = 500; } catch (...) { Framework::Logging::GetLogger("plugins")->error("Plugin HTTP handler '{}' threw non-std exception", pathStr); + res.body.clear(); res.status = 500; } }); @@ -149,14 +167,7 @@ namespace Framework::Integrations::Server::Plugins { static uint64_t VT_player_get_guid(FwPlayer *player) { if (!player) return 0; - auto *handle = reinterpret_cast(player); - if (!handle->host || !handle->host->worldEngine) return 0; - auto world = handle->host->worldEngine->GetWorld(); - if (!world) return 0; - flecs::entity e(*world, handle->entityId); - if (!e.is_valid()) return 0; - const auto *streamer = e.get(); - return streamer ? streamer->guid : 0; + return reinterpret_cast(player)->guid; } static size_t VT_player_get_nickname(FwPlayer *player, char *buf, size_t buf_size) { @@ -212,7 +223,7 @@ namespace Framework::Integrations::Server::Plugins { void DispatchPlayerConnect(HostImpl *impl, uint64_t entityId, uint64_t guid) { if (!impl) return; - PlayerHandle handle {impl, entityId}; + PlayerHandle handle {impl, entityId, guid}; /* Copy the slot list under the lock so callbacks can register more * slots without us iterating a mutating vector. */ std::vector snapshot; @@ -236,7 +247,7 @@ namespace Framework::Integrations::Server::Plugins { void DispatchPlayerDisconnect(HostImpl *impl, uint64_t entityId, uint64_t guid) { if (!impl) return; - PlayerHandle handle {impl, entityId}; + PlayerHandle handle {impl, entityId, guid}; std::vector snapshot; { std::lock_guard lock(impl->slotMutex); diff --git a/code/framework/src/integrations/server/plugins/host_impl.h b/code/framework/src/integrations/server/plugins/host_impl.h index acd8861f5..61a9d10e1 100644 --- a/code/framework/src/integrations/server/plugins/host_impl.h +++ b/code/framework/src/integrations/server/plugins/host_impl.h @@ -47,6 +47,13 @@ namespace Framework::Integrations::Server::Plugins { std::shared_ptr logger; /* scoped: "plugin:" */ + /* On-demand sub-loggers requested via logger_for(host, name). + * Keyed by the "plugin:" full identifier, holding + * the shared_ptr so the raw pointer handed to the plugin stays + * valid for the plugin's lifetime. */ + std::unordered_map> loggerCache; + std::mutex loggerCacheMutex; + /* Names of commands the plugin registered, so we can deregister * cleanly when the plugin unloads. */ std::vector registeredCommands; diff --git a/code/framework/src/integrations/server/plugins/sdk/fw_plugin.hpp b/code/framework/src/integrations/server/plugins/sdk/fw_plugin.hpp index 6aed1593f..8be1a6100 100644 --- a/code/framework/src/integrations/server/plugins/sdk/fw_plugin.hpp +++ b/code/framework/src/integrations/server/plugins/sdk/fw_plugin.hpp @@ -172,19 +172,25 @@ namespace Framework::Plugin { } bool RegisterHttpEndpoint(const std::string &path, HttpFn callback) { - auto slot = std::make_unique(std::move(callback)); - int rc = _vtable->register_http_endpoint( + auto slot = std::make_unique(std::move(callback)); + auto binding = std::make_unique(); + binding->vtable = _vtable; + binding->fn = slot.get(); + int rc = _vtable->register_http_endpoint( _host, path.c_str(), [](const char *method, const char *path_c, const char *body, size_t body_len, FwHttpResponse *response, void *userdata) { - const FwHostVTable *vt = reinterpret_cast(userdata)->vtable; - HttpResponse wrappedResponse(vt, response); - (*reinterpret_cast(userdata)->fn)(method, path_c, std::string_view(body, body_len), wrappedResponse); + auto *b = reinterpret_cast(userdata); + HttpResponse wrappedResponse(b->vtable, response); + (*b->fn)(method, path_c, std::string_view(body, body_len), wrappedResponse); }, - MakeHttpBinding(slot.get())); + binding.get()); if (rc == 0) { _httpSlots.push_back(std::move(slot)); + _httpBindings.push_back(std::move(binding)); return true; } + /* slot + binding destroyed on this return; the host did not + * store the userdata pointer on failure. */ return false; } @@ -206,15 +212,6 @@ namespace Framework::Plugin { }; std::vector> _httpBindings; - HostBindings *MakeHttpBinding(HttpFn *fn) { - auto binding = std::make_unique(); - binding->vtable = _vtable; - binding->fn = fn; - HostBindings *raw = binding.get(); - _httpBindings.push_back(std::move(binding)); - return raw; - } - bool RegisterPlayerEvent(int (*registerFn)(FwHost *, FwPlayerEventCallback, void *), PlayerEventFn callback) { auto slot = std::make_unique(std::move(callback)); auto binding = std::make_unique(); diff --git a/code/framework/src/integrations/server/plugins/sdk/fw_plugin_abi.h b/code/framework/src/integrations/server/plugins/sdk/fw_plugin_abi.h index 6aeab06cd..bc169d71a 100644 --- a/code/framework/src/integrations/server/plugins/sdk/fw_plugin_abi.h +++ b/code/framework/src/integrations/server/plugins/sdk/fw_plugin_abi.h @@ -76,7 +76,12 @@ * - All strings are null-terminated UTF-8 unless explicitly paired with a * length argument. * - Pointers handed from host to plugin are valid only for the duration of - * the call that received them, unless documented otherwise. + * the call that received them, with the following carve-outs: + * * FwLogger* returned by logger_for() is valid for the plugin's + * lifetime (until fw_plugin_shutdown returns). + * * FwHost* itself is valid for the plugin's lifetime. + * FwPlayer* and FwHttpResponse* are strictly call-scoped and must not + * be stored for later use. * - The plugin never returns heap memory to the host. To produce output * (e.g. HTTP response bodies), the plugin writes through host-provided * setter functions that own the allocation. From 0657d989ea934f9d9dea185f19f87d9372212a36 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Thu, 28 May 2026 11:39:21 +0200 Subject: [PATCH 4/5] Vendors: link libm and libdl to Lua targets on Linux Lua's math and dynamic-loader bindings reference libm (sin, cos, pow, etc.) and libdl (dlopen). On Debian/glibc these are separate libraries and must be linked explicitly, otherwise the lua54 executable fails to link with undefined references. --- vendors/lua-5.4.4/CMakeLists.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/vendors/lua-5.4.4/CMakeLists.txt b/vendors/lua-5.4.4/CMakeLists.txt index 0b5932a3a..d7e855761 100644 --- a/vendors/lua-5.4.4/CMakeLists.txt +++ b/vendors/lua-5.4.4/CMakeLists.txt @@ -63,3 +63,9 @@ set_target_properties(lua54_shared PROPERTIES OUTPUT_NAME "lua") # Executable add_executable(lua54_exe ${LUA_FILES}) set_target_properties(lua54_exe PROPERTIES OUTPUT_NAME "lua54") + +if(UNIX AND NOT APPLE) + target_link_libraries(lua54_exe PRIVATE m dl) + target_link_libraries(lua54_static PRIVATE m dl) + target_link_libraries(lua54_shared PRIVATE m dl) +endif() From c43d324ad3ecc6fe6cff4357b886a2aa305c3b49 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Thu, 28 May 2026 11:45:21 +0200 Subject: [PATCH 5/5] Docs: list Linux system dependencies for building Building on a fresh Debian/Ubuntu host fails to configure without libssl-dev, zlib1g-dev, libssh2-1-dev, and libcurl4-openssl-dev. Spell out the apt one-liner so contributors don't hit the errors one at a time. --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 5135a6754..fa8f0279e 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,16 @@ cd Framework ``` ### Build on macOS/Linux + +On Debian/Ubuntu, install the required system development packages first: + +```sh +sudo apt install build-essential cmake \ + libssl-dev zlib1g-dev libssh2-1-dev libcurl4-openssl-dev +``` + +Equivalents on other distributions: `openssl-devel`, `zlib-devel`, `libssh2-devel`, `libcurl-devel` (Fedora/RHEL). + ``` # Configure CMake project cmake -B build