Create a Plugin without Python

[!WARNING] No guarantee for the code examples.
The code examples in this chapter (Rust, C++) were generated by an AI. I have little knowledge of these languages myself and cannot guarantee that they are correct, complete, or error-free. Use them as a starting point and review them carefully before using them in production.

Overview

You can write a plugin in any language that produces a native Windows .exe. The system exclusively starts the file main.exe in your plugin folder — how it is produced (MSVC compiler, Rust/Cargo, MinGW, etc.) is irrelevant.

[!IMPORTANT] main.exe is the only required file of the plugin system.
Python plugins work exactly the same way: main.py is compiled to main.exe via PyInstaller. From the system's perspective there is no difference.

What you need to implement yourself (Python handles this automatically via the core module):

  • Registration protocol (--register-only)
  • Argument parsing (--gui-hidden)
  • HTTP server for /webhook
  • Reading configuration (YAML / JSON)
  • Data persistence

Folder Structure

src/plugins/
└── myplugin/
    ├── main.exe        ← started by the system (compiled from your code)
    ├── README.md
    └── version.txt

During the build, the entire src/plugins/myplugin/ folder is copied to build/release/plugins/myplugin/.


How the Registry Scanner Works

Before the main program starts any plugins, registry.exe runs first. It searches for all main.exe files in the plugins/ folder and executes each one with --register-only:

registry.exe
  ├── finds:    plugins/myplugin/main.exe
  ├── calls:    main.exe --register-only   (cwd = plugins/myplugin/)
  ├── reads stdout, parses first valid JSON object
  └── saves metadata to PLUGIN_REGISTRY.json

start.py then reads PLUGIN_REGISTRY.json and starts every enabled main.exe (this time without --register-only).

[!NOTE] The scanner caches registration results based on file size and modification time. If you recompile main.exe, it will automatically be rescanned on the next start.


The Registration Protocol

Required Format

When your plugin is started with --register-only, it must print a single line to stdout in the following format and then exit with code 0:

REGISTER_PLUGIN: {"name":"MyPlugin","path":"C:\\absolute\\path\\to\\main.exe","enable":true,"level":4,"ics":false}

The REGISTER_PLUGIN: prefix is recommended (exactly as Python outputs it), but the scanner also accepts a line that is directly parseable as a JSON object.

Required Fields

FieldTypeDescription
namestringUnique name of the plugin
pathstringAbsolute path to main.exe
enableboolWhether the plugin is started on launch
levelintVisibility level (see below)
icsboolDoes the plugin have a GUI window?

Visibility Level (level)

Controls whether the plugin's console window is shown, depending on log_level in config.yaml:

LevelMeaning
0Forbidden – overrides all visibility rules, never use!
1For very important output only
2Main programs
3Background services
4Debug/Development ← recommended for custom plugins
5Forbidden – overrides all visibility rules, never use!

[!NOTE] Level 0 and 5 must not be used. When log_level = 0 or log_level = 5 is set in config.yaml, these values override all visibility rules for all programs and plugins. No plugin or program may set level 0 or 5.

ICS vs. DCS

  • ics: false → Plugin without GUI (HTTP server running in the background only). This is the default.
  • ics: true → Plugin opens a window. If control_method in config.yaml is set to DCS, your plugin will be started with --gui-hidden (do not open a window).

The path Value

The path value must be the absolute path to main.exe. Since the scanner calls your plugin using its full absolute path, you can derive it from the process itself:

  • Rust: std::env::current_exe() – the most reliable method
  • C++: Win32 API GetModuleFileNameA(NULL, buf, MAX_PATH) or std::filesystem::absolute(argv[0])

Argument Handling

Your plugin must recognize at least two arguments:

ArgumentBehavior
--register-onlyPrint JSON, exit immediately
--gui-hiddenDo not open a window (only relevant if ics: true)

Implementing the Webhook Server

The Minecraft plugin sends HTTP POST requests to all configured URLs when events occur. Your plugin can start an HTTP server and provide the /webhook endpoint.

Event Payload

{
    "load_type": "INGAME_GAMEPLAY",
    "event": "player_death",
    "message": "Player died from fall damage"
}

load_type can be e.g. INGAME_GAMEPLAY or STARTUP. event corresponds to the event name from configServerAPI.yml.

Common Events

EventActive by default
player_death
player_respawn
player_join
player_quit
block_break
entity_death

The full list can be found in configServerAPI.yml.

Configuring the Port

Add a port entry for your plugin in config.yaml:

MyPlugin:
  Enable: true
  WebServerPort: 8888

Then add the webhook URL to configServerAPI.yml (Minecraft plugin config):

webhooks:
  urls:
    - "http://localhost:7777/webhook"
    - "http://localhost:7878/webhook"
    - "http://localhost:7979/webhook"
    - "http://localhost:8080/webhook"
    - "http://localhost:8888/webhook"   # your plugin

[!IMPORTANT] Every port number in the system must be unique. Never use a port that is already taken.


Paths at Runtime

When main.exe is running, all important directories can be derived from its own path:

build/release/
├── config/
│   └── config.yaml       ← configuration
├── data/                 ← persistent data
├── logs/                 ← log files
└── plugins/
    └── myplugin/
        └── main.exe      ← your plugin
VariableCalculationExample
BASE_DIRDirectory of main.exe…/plugins/myplugin/
ROOT_DIRBASE_DIR/../..…/build/release/
CONFIG_FILEROOT_DIR/config/config.yaml
DATA_DIRROOT_DIR/data/
LOGS_DIRROOT_DIR/logs/

Rust:

#![allow(unused)]
fn main() {
let exe_path = std::env::current_exe().unwrap();
let base_dir = exe_path.parent().unwrap();        // plugins/myplugin/
let root_dir = base_dir.parent().unwrap()
                        .parent().unwrap();        // build/release/
let config_file = root_dir.join("config").join("config.yaml");
let data_dir    = root_dir.join("data");
let logs_dir    = root_dir.join("logs");
}

C++:

#include <filesystem>
namespace fs = std::filesystem;

char buf[MAX_PATH];
GetModuleFileNameA(NULL, buf, MAX_PATH);
fs::path base_dir    = fs::path(buf).parent_path();          // plugins/myplugin/
fs::path root_dir    = base_dir.parent_path().parent_path(); // build/release/
fs::path config_file = root_dir / "config" / "config.yaml";
fs::path data_dir    = root_dir / "data";
fs::path logs_dir    = root_dir / "logs";

Reading Configuration

config.yaml is a YAML file. Read it when your plugin starts:

Rust (with serde_yaml):

#![allow(unused)]
fn main() {
let content = std::fs::read_to_string(&config_file).unwrap_or_default();
let cfg: serde_yaml::Value = serde_yaml::from_str(&content).unwrap_or(serde_yaml::Value::Null);
let port    = cfg["MyPlugin"]["WebServerPort"].as_u64().unwrap_or(8888) as u16;
let enabled = cfg["MyPlugin"]["Enable"].as_bool().unwrap_or(true);
}

C++ (with yaml-cpp):

YAML::Node cfg = YAML::LoadFile(config_file.string());
int  port    = cfg["MyPlugin"]["WebServerPort"].as<int>(8888);
bool enabled = cfg["MyPlugin"]["Enable"].as<bool>(true);

If the file is missing or a key is absent, always fall back to a default value — the plugin must never crash because of a missing config entry.


Data Persistence

Store persistent data (counters, state, window size) as a JSON file in DATA_DIR:

build/release/data/myplugin_state.json

Write atomically (first to .tmp, then rename) to avoid data loss on unexpected shutdown.


Communication with Other Plugins

Plugins communicate via HTTP on localhost. Ports are defined in config.yaml:

WinCounter:
  WebServerPort: 8080

Rust (with ureq):

#![allow(unused)]
fn main() {
// Fire-and-forget (no waiting for response)
std::thread::spawn(|| {
    let _ = ureq::post("http://localhost:8080/add?amount=1").call();
});
}

C++ (with cpp-httplib):

httplib::Client cli("localhost", 8080);
cli.set_connection_timeout(2);
auto res = cli.Post("/add?amount=1");
if (!res || res->status != 200) {
    // Plugin unreachable – log error, do not crash
}

[!NOTE] The other plugin may be offline or not yet started. Always set a timeout and handle errors.


Full Example – Rust

Demonstrates all required parts: registration, webhook server, config reading, data persistence.

Dependencies (Cargo.toml):

[dependencies]
tiny_http   = "0.12"
serde       = { version = "1", features = ["derive"] }
serde_json  = "1"
serde_yaml  = "0.9"

src/main.rs:

use std::env;
use std::fs;
use std::io::Read;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tiny_http::{Response, Server};

// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
fn exe_path() -> PathBuf {
    env::current_exe().expect("Cannot determine exe path")
}

fn base_dir() -> PathBuf {
    exe_path().parent().unwrap().to_path_buf()
}

fn root_dir() -> PathBuf {
    base_dir().parent().unwrap().parent().unwrap().to_path_buf()
}

// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
#[derive(serde::Serialize, serde::Deserialize, Default)]
struct State {
    count: u64,
}

fn load_state(path: &PathBuf) -> State {
    if path.exists() {
        let s = fs::read_to_string(path).unwrap_or_default();
        serde_json::from_str(&s).unwrap_or_default()
    } else {
        State::default()
    }
}

fn save_state(path: &PathBuf, state: &State) {
    let tmp = path.with_extension("tmp");
    fs::write(&tmp, serde_json::to_string_pretty(state).unwrap()).ok();
    fs::rename(&tmp, path).ok();
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
fn main() {
    let args: Vec<String> = env::args().collect();

    // --- Registration ---
    if args.iter().any(|a| a == "--register-only") {
        let exe = exe_path().to_string_lossy().replace('\\', "\\\\");

        let root = root_dir();
        let config_file = root.join("config").join("config.yaml");
        let content = fs::read_to_string(&config_file).unwrap_or_default();
        let cfg: serde_yaml::Value =
            serde_yaml::from_str(&content).unwrap_or(serde_yaml::Value::Null);
        let enabled = cfg["MyPlugin"]["Enable"].as_bool().unwrap_or(true);

        println!(
            r#"REGISTER_PLUGIN: {{"name":"MyPlugin","path":"{exe}","enable":{enabled},"level":4,"ics":false}}"#
        );
        std::process::exit(0);
    }

    let gui_hidden = args.iter().any(|a| a == "--gui-hidden");
    // gui_hidden is not needed here since ics=false (no window)
    let _ = gui_hidden;

    // --- Load configuration ---
    let root = root_dir();
    let config_file = root.join("config").join("config.yaml");
    let content = fs::read_to_string(&config_file).unwrap_or_default();
    let cfg: serde_yaml::Value =
        serde_yaml::from_str(&content).unwrap_or(serde_yaml::Value::Null);

    let port: u16 = cfg["MyPlugin"]["WebServerPort"]
        .as_u64()
        .unwrap_or(8888) as u16;

    // --- Load state ---
    let data_dir = root.join("data");
    fs::create_dir_all(&data_dir).ok();
    let state_file = data_dir.join("myplugin_state.json");
    let state = Arc::new(Mutex::new(load_state(&state_file)));

    // --- Start HTTP server ---
    let server = Server::http(format!("127.0.0.1:{port}"))
        .expect("Could not start HTTP server");

    println!("[MyPlugin] running on port {port}");

    for mut request in server.incoming_requests() {
        let url    = request.url().to_string();
        let method = request.method().as_str().to_string();

        if url == "/webhook" && method == "POST" {
            let mut body = String::new();
            request.as_reader().read_to_string(&mut body).ok();

            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) {
                let event = json["event"].as_str().unwrap_or("");
                if event == "player_death" {
                    let mut s = state.lock().unwrap();
                    s.count += 1;
                    println!("[MyPlugin] Deaths: {}", s.count);
                    save_state(&state_file, &s);
                }
            }

            let response = Response::from_string(r#"{"status":"ok"}"#)
                .with_header("Content-Type: application/json".parse().unwrap());
            request.respond(response).ok();

        } else if url == "/" && method == "GET" {
            let s    = state.lock().unwrap();
            let body = format!(r#"{{"count":{}}}"#, s.count);
            let response = Response::from_string(body)
                .with_header("Content-Type: application/json".parse().unwrap());
            request.respond(response).ok();

        } else {
            request.respond(Response::from_string("Not Found").with_status_code(404)).ok();
        }
    }
}

Full Example – C++

Uses cpp-httplib (single-header) and nlohmann/json (single-header).

// Build (MSVC):
//   cl /std:c++17 /EHsc main.cpp /Fe:main.exe
// Build (MinGW):
//   g++ -std=c++17 -O2 main.cpp -o main.exe -lws2_32

#define CPPHTTPLIB_OPENSSL_SUPPORT 0
#include "httplib.h"    // https://github.com/yhirose/cpp-httplib
#include "json.hpp"     // https://github.com/nlohmann/json

#include <windows.h>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <mutex>
#include <string>

namespace fs = std::filesystem;
using json   = nlohmann::json;

// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
fs::path get_exe_path() {
    char buf[MAX_PATH];
    GetModuleFileNameA(NULL, buf, MAX_PATH);
    return fs::path(buf);
}

fs::path base_dir() { return get_exe_path().parent_path(); }
fs::path root_dir() { return base_dir().parent_path().parent_path(); }

// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
struct State { uint64_t count = 0; };
std::mutex state_mutex;
State g_state;

void save_state(const fs::path& path) {
    fs::path tmp = path;
    tmp.replace_extension(".tmp");
    std::ofstream f(tmp);
    f << json{{"count", g_state.count}}.dump(2);
    f.close();
    fs::rename(tmp, path);
}

void load_state(const fs::path& path) {
    if (!fs::exists(path)) return;
    std::ifstream f(path);
    try {
        json j; f >> j;
        g_state.count = j.value("count", 0ULL);
    } catch (...) {}
}

// ---------------------------------------------------------------------------
// Config reading (simplified – no yaml-cpp dependency required)
// For full YAML support consider yaml-cpp: https://github.com/jbeder/yaml-cpp
// ---------------------------------------------------------------------------
uint16_t read_port(const fs::path& config_file, uint16_t default_port) {
    if (!fs::exists(config_file)) return default_port;
    std::ifstream f(config_file);
    std::string line;
    bool in_section = false;
    while (std::getline(f, line)) {
        if (!line.empty() && line[0] != ' ' && line[0] != '#')
            in_section = (line.find("MyPlugin:") != std::string::npos);
        if (in_section) {
            auto pos = line.find("WebServerPort:");
            if (pos != std::string::npos) {
                try { return static_cast<uint16_t>(std::stoi(line.substr(pos + 14))); }
                catch (...) {}
            }
        }
    }
    return default_port;
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
int main(int argc, char* argv[]) {
    // --- Registration ---
    for (int i = 1; i < argc; ++i) {
        if (std::string(argv[i]) == "--register-only") {
            std::string exe = get_exe_path().string();
            std::string escaped;
            for (char c : exe) {
                if (c == '\\') escaped += "\\\\";
                else escaped += c;
            }
            std::cout << "REGISTER_PLUGIN: "
                      << "{\"name\":\"MyPlugin\","
                      << "\"path\":\"" << escaped << "\","
                      << "\"enable\":true,"
                      << "\"level\":4,"
                      << "\"ics\":false}"
                      << std::endl;
            return 0;
        }
    }

    bool gui_hidden = false;
    for (int i = 1; i < argc; ++i)
        if (std::string(argv[i]) == "--gui-hidden") gui_hidden = true;
    (void)gui_hidden; // ics=false, not relevant

    // --- Paths & configuration ---
    fs::path root        = root_dir();
    fs::path config_file = root / "config" / "config.yaml";
    fs::path data_dir    = root / "data";
    fs::create_directories(data_dir);
    fs::path state_file  = data_dir / "myplugin_state.json";

    uint16_t port = read_port(config_file, 8888);

    // --- Load state ---
    load_state(state_file);

    // --- HTTP server ---
    httplib::Server svr;
    svr.set_error_handler([](const auto&, auto& res) {
        res.set_content(R"({"status":"error"})", "application/json");
        res.status = 500;
    });

    svr.Get("/", [&](const httplib::Request&, httplib::Response& res) {
        std::lock_guard<std::mutex> lock(state_mutex);
        res.set_content("{\"count\":" + std::to_string(g_state.count) + "}",
                        "application/json");
    });

    svr.Post("/webhook", [&](const httplib::Request& req, httplib::Response& res) {
        try {
            auto j = json::parse(req.body);
            std::string event = j.value("event", "");
            if (event == "player_death") {
                std::lock_guard<std::mutex> lock(state_mutex);
                g_state.count++;
                std::cout << "[MyPlugin] Deaths: " << g_state.count << "\n";
                save_state(state_file);
            }
        } catch (const std::exception& e) {
            std::cerr << "[MyPlugin] Webhook error: " << e.what() << "\n";
        }
        res.set_content(R"({"status":"ok"})", "application/json");
    });

    std::cout << "[MyPlugin] running on port " << port << "\n";
    svr.listen("127.0.0.1", port);
    return 0;
}

Error Handling & Best Practices

[!WARNING] The system does not automatically restart a crashed plugin.

SituationWhat to do
Config file missingUse default values, do not crash
Port already in usePrint error message, exit cleanly
HTTP request does not returnAlways set a timeout
Unhandled crashTop-level exception handler with log output
JSON parse error in webhooktry/catch, still return HTTP 200

Logging

Write logs to ROOT_DIR/logs/myplugin.log. Use append mode and log at minimum:

  • Plugin start with port number
  • Each received event type
  • Every error with a timestamp