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.exeis the only required file of the plugin system.
Python plugins work exactly the same way:main.pyis compiled tomain.exevia 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
| Field | Type | Description |
|---|---|---|
name | string | Unique name of the plugin |
path | string | Absolute path to main.exe |
enable | bool | Whether the plugin is started on launch |
level | int | Visibility level (see below) |
ics | bool | Does 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:
| Level | Meaning |
|---|---|
| 0 | Forbidden – overrides all visibility rules, never use! |
| 1 | For very important output only |
| 2 | Main programs |
| 3 | Background services |
| 4 | Debug/Development ← recommended for custom plugins |
| 5 | Forbidden – overrides all visibility rules, never use! |
[!NOTE] Level 0 and 5 must not be used. When
log_level = 0orlog_level = 5is set inconfig.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. Ifcontrol_methodinconfig.yamlis set toDCS, 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)orstd::filesystem::absolute(argv[0])
Argument Handling
Your plugin must recognize at least two arguments:
| Argument | Behavior |
|---|---|
--register-only | Print JSON, exit immediately |
--gui-hidden | Do 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
| Event | Active 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
| Variable | Calculation | Example |
|---|---|---|
BASE_DIR | Directory of main.exe | …/plugins/myplugin/ |
ROOT_DIR | BASE_DIR/../.. | …/build/release/ |
CONFIG_FILE | ROOT_DIR/config/config.yaml | |
DATA_DIR | ROOT_DIR/data/ | |
LOGS_DIR | ROOT_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.
| Situation | What to do |
|---|---|
| Config file missing | Use default values, do not crash |
| Port already in use | Print error message, exit cleanly |
| HTTP request does not return | Always set a timeout |
| Unhandled crash | Top-level exception handler with log output |
| JSON parse error in webhook | try/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