Module Development Guide
How to build a Python module that adds a new game to Saba-chan. Covers the actual lifecycle.py function signatures and protocol.
Module Development Guide (Python)
The Saba-chan daemon doesn't know the per-game specifics of any title. Instead, each game is described by a Python module in a structured way. Adding a single module is enough to bring a new game under Saba-chan's management.
This document is based on the actual implementations (minecraft, palworld, zomboid) in the saba-chan-modules repository.
1. Module File Structure
%APPDATA%\saba-chan\modules\<module_name>\
├── module.toml ← required. Metadata + default settings
├── lifecycle.py ← required. Functions invoked by the daemon
├── icon.png ← optional. For GUI display
├── locales/
│ ├── en.json
│ ├── ko.json
│ └── ... ← localized strings
├── test_lifecycle.py ← optional. pytest unit tests (strongly recommended)
└── _sample_log.txt ← optional. For testing log syntax highlighting
The [module].name in module.toml must be lowercase ASCII and must match the folder name.
2. module.toml Specification
A summarized example based on the real minecraft/module.toml:
[module]
name = "minecraft" # Same as the folder name
version = "0.1.0"
display_name = "Minecraft"
entry = "lifecycle.py" # Entry point (always this value)
icon = "icon.png"
log_pattern = '(?P<level>INFO|WARN|ERROR|DEBUG)'
[protocols]
supported = ["rcon", "stdin"] # Supported protocols
default = "rcon"
interaction_mode = "console" # "console" or "commands"
[config]
executable_path = "" # Filled in with the real jar path after install
server_executable = "java"
process_name = "javaw"
default_port = 25565
rcon_port = 25575
stop_command = "stop" # Safe-shutdown command
[install]
requires_extensions = [] # e.g., ["steamcmd", "ue4-ini"]
[credential_map]
# (optional) Maps the return values of get_credentials() to module settings keys
# Palworld example: rest_password = "AdminPassword"
Section meanings:
[module]— Metadata.log_patternis used for syntax highlighting in the GUI console.[protocols]— Decides which channel the daemon uses to send commands. Choose betweenrcon,rest, andstdin.[config]— Module defaults. The user'sinstances.json.settingsoverrides these.[install].requires_extensions— Extensions that must be ensured at install time. Palworld needs SteamCMD + UE4-INI.[credential_map]— Maps keys returned fromget_credentials()to fields in the module's settings file.
3. lifecycle.py Function Contract
Whenever needed, the daemon spawns a fresh Python process to call functions in this file. Each function runs in its own independent process, with input and output exchanged as JSON. There are 8 functions you must implement, and their names and signatures must match exactly.
3.1 Required Functions
def get_launch_command(config: dict) -> str:
"""Builds the server launch command string.
Example config:
{
"install_dir": "C:\\Servers\\Minecraft",
"port": 25565,
"rcon_port": 25575,
... # module.toml [config] merged with instances.json settings
}
Returns a shell command string, e.g.:
'java -Xmx1024M -Xms1024M -jar "C:/Servers/Minecraft/server.jar" nogui'
"""
def configure(config: dict) -> dict:
"""Writes config files such as server.properties under install_dir.
Returns: { "success": bool, "error": str | None }
"""
def get_credentials(config: dict) -> dict:
"""Generates or reads RCON / REST / admin credentials and returns them.
Example return:
{
"rcon_password": "…",
"rest_username": "admin",
"rest_password": "…"
}
If credential_map is defined, the daemon will automatically reflect these
values into the module's settings file.
"""
def get_installed_version(config: dict) -> str:
"""The version string actually installed under install_dir."""
def get_version_details(config: dict) -> dict:
"""Detailed information about a specific version.
Example return:
{
"version": "1.20.1",
"type": "release",
"download_url": "https://…/server.jar",
"requirements": {"java": ">=17"}
}
"""
def install_server(config: dict) -> dict:
"""Downloads/clones the original files. For long-running work, emit
PROGRESS:{"step":1,"total":5,"percent":25,"message":"…"}
lines on stderr to report progress.
Returns: { "success": bool, "error": str | None, "installed_version": str }
"""
def validate(config: dict, server_dir: str) -> dict:
"""Checks preconditions (Java installed, ports free, etc.).
Returns: { "valid": bool, "errors": [str], "warnings": [str] }
"""
def diagnose(config: dict, server_dir: str) -> dict:
"""Diagnoses problems based on current logs/state.
Example return:
{
"issues": [{"problem": "OutOfMemory", "solution": "increase -Xmx"}],
"logs": {...}
}
"""
3.2 Optional Hooks
def on_start(config: dict) -> None:
"""Called immediately after the server transitions to the Running state."""
def on_stop(config: dict) -> None:
"""Called immediately after the server transitions to the Stopped state."""
Functions named
status,stop, orcommanddo not exist. State detection is performed by the daemon directly, using[config].process_nameand port occupancy; the stop command is sent by the daemon over the channel chosen by[protocols], using[config].stop_command. Modules do not implement stop logic themselves.
4. Subprocess Protocol
How the daemon calls module functions is implemented in src/plugin/mod.rs. In summary:
- Spawn a process with the portable Python executable + the module folder's
lifecycle.pyas arguments. - Inject environment variables:
DAEMON_API_URL=http://127.0.0.1:57474PYTHONIOENCODING=utf-8PYTHONUNBUFFERED=1PYTHONPATH=<modules_dir>;<extensions_dir>SABA_ENABLED_EXTENSIONS=docker;music;steamcmd(example)
- Send a single JSON request on stdin:
{ "action": "get_launch_command", "config": { ... } } - The last line on stdout (a JSON value) is taken as the result.
- stderr is treated as logs. However, lines prefixed with
PROGRESS:are parsed as progress events and surface in the response ofGET /api/provision-progress/:name. - The process exits after a single function call. It is not a long-lived process.
4.1 Progress Reporting Example
# Inside install_server
import json, sys
def report(step, total, percent, msg):
sys.stderr.write("PROGRESS:" + json.dumps({
"step": step, "total": total, "percent": percent, "message": msg
}) + "\n")
sys.stderr.flush()
report(1, 3, 10, "Starting download")
# … actual download …
report(2, 3, 70, "Extracting archive")
# …
report(3, 3, 100, "Installation complete")
5. Shared Utilities
Because PYTHONPATH includes both the modules directory and the extensions directory, you can import the following:
from i18n import I18n # loader for locales/*.json inside the module
from daemon_rcon import rcon_command # wrapper for the daemon's RCON API
i18n = I18n(os.path.join(os.path.dirname(__file__), "locales"))
msg = i18n.get("install_success", lang="ko")
result = rcon_command(host="127.0.0.1",
port=config["rcon_port"],
password=config["rcon_password"],
command="list")
In addition, any active extension that exposes a Python module (e.g., ue4_ini.py, steamcmd.py) can be imported as import ue4_ini.
6. Reference: Built-in Modules
| Module | Default port | Protocol | Required extensions | Notes |
|---|---|---|---|---|
minecraft | 25565 (+ 25575 RCON) | RCON (default) / stdin | none | Requires the portable JRE |
palworld | 8211 | REST API | steamcmd, ue4-ini | Uses credential_map to map rest_password ↔ AdminPassword |
zomboid | 16261 (+ 27015 RCON) | RCON / stdin | steamcmd | stop_command = "quit" |
All three are made up of just a module.toml + lifecycle.py pair. When building a new module, we recommend cloning whichever of these is closest in structure as your starting point.
7. Development Rules (Mandatory)
This project keeps system-level rules in .github/copilot-instructions.md. Module PRs that violate the following will be rejected.
- No dependency on daemon code. Use only the config, environment variables, and APIs the daemon provides.
- No hardcoded paths. Use
os.path.joinexclusively. Do not mix slashes and backslashes. - Do not swallow errors. Print exceptions with their full traceback to stderr — the daemon collects it.
- Consistent encoding. JSON uses
utf-8-sig; for TOML, strip BOM manually. - TDD. Add at least minimal unit tests in
test_lifecycle.py. CI runs them withpytest. - Verify field-name mappings. Cross-check, in both directions, which fields in
module.tomlflow into which fields of the server config file.
8. Debugging Tips
- Module functions run in their own process, so
print()output that arrives before the final stdout JSON will break parsing. Always send debug output to stderr. - If progress isn't showing up in the GUI, verify that each stderr line starts with exactly the
PROGRESS:prefix and that the JSON is valid. - After modifying a module, you must reflect the change to
%APPDATA%\saba-chan\modules\<name>\for the daemon to load the latest code. Editing only the source will leave the old version running.