Module Development Guide

How to build a Python module that adds a new game to Saba-chan. Covers the module.toml schema and the actual lifecycle.py function contract and protocol.

Module Development Guide (Python)

The Saba-chan daemon doesn't know game-specific details. Instead, each game is described by a Python module. Adding one module lets Saba-chan manage a new game.

This document is based on the real modules in the saba-chan-modules repository (minecraft, palworld, zomboid), the annotated template __example, and the daemon code.


1. Module File Structure

%APPDATA%\saba-chan\modules\<module_name>\
├── module.toml            ← required. Declarations (schema v1)
├── lifecycle.py           ← required. Entry file named by [module].entry
├── icon.png               ← optional. Set with [module].icon
├── locales/
│   ├── en.json            ← reference language
│   ├── ko.json
│   └── ...                ← UI strings
└── test_lifecycle.py      ← optional. pytest unit tests (recommended)
  • Name the folder the same as [module].name in module.toml, in lowercase English.
  • The daemon only reads first-level folders under the modules root that contain a module.toml, and it won't load a module whose [module].entry file is missing.
  • To start, we recommend copying __example/__example__module.toml to module.toml and __example/__example__lifecycle.py to lifecycle.py, then deleting what you don't need.

2. module.toml Specification

A summarized example from the top of the real minecraft/module.toml:

[module]
schema_version = 1                # required. Unknown keys or invalid values make loading fail
name = "minecraft"                # same as the folder name
version = "0.1.0"
display_name = "Minecraft"
game_name = "Minecraft"
entry = "lifecycle.py"            # Python file the daemon runs
icon = "icon.png"
log_pattern = '^\[\d{2}:\d{2}:\d{2}(?:\] \[[^\]]*/| )(?P<level>INFO|WARN|ERROR|DEBUG|FATAL)\]'

[module.dependencies]
saba-core = ">=0.1.5"             # component → minimum version (">=" only)

[permissions]
network = true                    # capabilities the code uses
spawn_process = true

[protocols]
supported = ["stdin"]             # from "stdin" / "rest"
default = "stdin"
interaction_mode = "console"      # "console" or "commands"

[config]
server_executable = "server.jar"
process_name = "java.exe"
default_port = 25565
announce_command = "say"          # [[commands.fields]] name used for announcements
# stop_command defaults to "stop" when omitted

[backup]
paths = ["{level_name}", "{level_name}_nether", "{level_name}_the_end"]
save_command = "save-all"

[install]
method = "download"               # "manual" (default) / "download" / an extension id (e.g. "steamcmd")

[detection]
dir_signatures = ["server.jar"]
graceful_exit_patterns = ["Stopping the server"]
crash_artifacts = ["crash-reports/*.txt", "hs_err_pid*.log"]

What each section means:

  • [module] — Metadata. name, version, and entry are required. log_pattern extracts the log level of a console line through a (?P<level>…) group. Keys in [module.dependencies] are saba-core, ext-<extension id>, and so on.
  • [permissions] — Declares the risky capabilities the code uses (network, spawn_process, native_code, dynamic_code, env_secrets, fs_outside_instance).
  • [protocols] — supported only takes "stdin" and "rest". For security, Saba-chan does not use RCON, and modules turn RCON off when the game has an RCON setting. interaction_mode decides whether to show a console view (console) or only command buttons (commands).
  • [config] — server_executable, executable_path, process_name, default_port, stop_command (stop command sent to the console, default stop), stop_timeout (seconds, default 30), announce_command. Game settings go in [[settings.fields]], not here.
  • [[settings.fields]] — Items on the instance settings screen (name, type, label, default, options, group, etc.). Values the user changes are stored as instance settings and arrive in the config of each function call.
  • [[commands.fields]] — Game commands. With method = "console" (+ console_template) the command goes to the console (stdin), with "rest" to the game's REST API, and with "module" (default) to command() in lifecycle.py. risk (safe/caution/danger) and description are read by AI command interpretation.
  • [install] — Install method, app_id, requires_extensions (list of required extension ids), etc. Palworld requires ["steamcmd", "ue4-ini"].
  • [detection] — Recognizing existing server folders (dir_signatures), finding processes (process_patterns, cmd_patterns), and crash detection (graceful_exit_patterns, crash_artifacts). Only exits judged as crashes are restarted automatically.
  • [backup] — Paths to include in backups (paths), the command to run right before a backup (save_command), and hook = true when the paths are computed in code.
  • [credential_map] — Maps daemon standard keys to game setting keys. Palworld example: rest_password = "AdminPassword".
  • There are also [launch], [[agreements]], [[presets]], [[queries]], [aliases], [errors], [[syntax_highlight.rules]], and [extensions.<id>]. For the full key list, see docs/module-spec.md in the saba-chan-modules repository.

The old [update] section no longer exists. Module updates are delivered through Saba-chan's signed release manifest.


3. lifecycle.py Function Contract

Whenever it needs to, the daemon spawns a new Python process to call one function in this file. Every function takes a single config: dict and returns a dict, and must be registered in the FUNCTIONS table at the end of the file. Modules have no start function: the daemon launches the server itself from the result of get_launch_command.

3.1 Required Functions

The only required function is get_launch_command.

import os
from daemon_rcon import config_value, run_lifecycle

def get_launch_command(config: dict) -> dict:
    """Return how to launch the server. The daemon spawns the process."""
    exe = config.get("server_executable", "")
    if not exe or not os.path.exists(exe):
        return {"success": False, "message": f"Server executable not found: {exe}"}
    port = config_value(config, "port", 7777)
    return {
        "success": True,
        "program": exe,
        "args": [f"-port={port}"],
        "working_dir": config.get("working_dir") or os.path.dirname(exe),
        "env_vars": {},
    }

FUNCTIONS = {"get_launch_command": get_launch_command}

if __name__ == "__main__":
    run_lifecycle(FUNCTIONS)
  • The daemon spreads the instance settings (module_settings) into the top level of config. Reading them with config_value(config, key, default) is the safe way.
  • If config contains extra_args (launch arguments the user added), place them where they belong and return extra_args_placed: true. Otherwise the daemon appends them to the end of args.
  • preview: true means a launch-argument preview. Don't write settings files; just return the launch information.

3.2 Optional Functions

Implement only the features you support, and if you implement one, always add it to the FUNCTIONS table.

FunctionWhen the daemon calls itReturns
validatePre-start checksuccess, issues[] (the server won't start if any has severity: "critical")
stopStopping servers without a console, and non-managed serverssuccess
statusNon-managed status querystatus, pid
commandCommands with method = "module"success, message
get_credentialsWhen REST connection info is neededrest_username, rest_password
configure / read_properties / reset_properties / reset_serverSettings screen save / read / resetsuccess (+ values read)
import_settingsImporting an existing server, reading settingssettings{}
list_versions / get_version_detailsVersion list / detailsVersion info
install_serverInstall and updatesuccess, executable_path, install_path
get_installed_versionInstalled version queryversion
accept_agreementsAfter the user accepts [[agreements]]success
diagnose_logLog diagnosissuccess
backup_pathsBackups (when [backup] hook = true)success, paths[]
instance_commandsCommand list (when [commands] hook = true)success, commands[]

Common rules:

  • Every response includes success: bool and message: str. If user action is needed, include action_required: "<code>".
  • If a required extension is missing, answer with error_code: "extension_required" and missing_extensions: [...].
  • For an unknown function name, answer {"success": false, "message": "Unknown function: X"} (run_lifecycle() handles this).

For a managed server with a console, the daemon stops it by sending [config].stop_command to the console (stdin) and waiting. The module's stop() is only called when there is no console; it must follow the order graceful stop request → wait → force kill, and only touch the pid the daemon passed in.


4. Subprocess Protocol

How the daemon calls module functions is implemented in src/plugin/mod.rs of the Saba-chan repository. In summary:

  1. It spawns python lifecycle.py <function name>, using the Python environment managed by the daemon.
  2. Injected environment variables:
    • PYTHONIOENCODING=utf-8, PYTHONUNBUFFERED=1
    • PYTHONPATH — the modules folder holding the shared files (i18n.py, daemon_rcon.py) and the parent of the extensions folder
    • SABA_IPC_URL, SABA_IPC_PORT — daemon API address
    • SABA_TOKEN_PATH — path of the file holding the daemon API token
    • SABA_LANG — GUI language
    • SABA_ENABLED_EXTENSIONS — comma-separated ids of enabled extensions (e.g. steamcmd,ue4-ini)
  3. It writes one config JSON object to stdin and closes it.
  4. It parses the whole stdout as JSON; if that fails, it uses the last non-empty line as the result.
  5. stderr is the log. Lines starting with PROGRESS: are parsed as progress events and used for install progress display.
  6. The exit code must be 0. If it isn't, the daemon treats the call as a generic error even if stdout contains success: false.
  7. The process exits after each call. The default time limit is 120 seconds; only install_server during provisioning and updates is given longer (1800 seconds).

Unverified modules (Windows): Modules not downloaded from Saba-chan's signed catalog, or changed after download, are scanned with antivirus (AMSI / Defender) before running and run inside an isolated AppContainer. In that case they can't reach loopback (the daemon API, local game server REST), don't receive SABA_TOKEN_PATH, and can only write to limited folders such as the instance folder. Keep this in mind when testing a module you built yourself.

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")
# …
report(3, 3, 100, "Install complete")

Recognized fields are percent, message, step, total, and label.


5. Shared Utilities

The daemon places the shared files i18n.py and daemon_rcon.py in the modules folder and adds it to PYTHONPATH. Don't copy them into your module folder.

import os
from i18n import I18n
from daemon_rcon import daemon_stdin_command, wait_for_exit, force_kill

MODULE_DIR = os.path.dirname(os.path.abspath(__file__))
i18n = I18n(MODULE_DIR)                          # MODULE_DIR/locales/<SABA_LANG>.json
msg = i18n.t("errors.failed_to_stop", error="…")  # replaces {{error}} in the string

daemon_stdin_command(config["instance_id"], "save-all")  # send one line to the server console

daemon_rcon.py keeps its name for historical reasons only; it never opens an RCON socket. Main helpers:

FunctionPurpose
daemon_stdin_command(instance_id, line)Send a command to the console of a daemon-managed server
daemon_post(path, payload)Call other daemon APIs (token added automatically)
is_process_alive, wait_for_exit, force_killPer-PID check / wait / force kill
config_value(config, key, default)Find a setting value in config
run_lifecycle(FUNCTIONS)Entry point for python lifecycle.py <function>

Enabled extensions can be imported, e.g. from extensions.steamcmd import SteamCMD or import extensions.ue4_ini. Disabled extensions are blocked by an import guard (ModuleNotFoundError), so import inside the function and answer extension_required on failure.


6. Reference: Built-in Modules

ModuleDefault PortProtocolRequired ExtensionsNotes
minecraft25565stdin (console)Nonedownload install, Java auto-detect/download, RCON forced off
palworld8211REST APIsteamcmd, ue4-iniGame without a console. credential_map maps rest_password ↔ AdminPassword
zomboid16261stdin (console)steamcmdstop_command = "quit", [backup] hook = true

All are built around module.toml and lifecycle.py. When creating a new module, we recommend starting from __example or whichever of these three is closest.


7. Development Rules (Mandatory)

Module PRs must follow these rules.

  1. No dependency on daemon code. Use only the config, environment variables, and APIs the daemon provides.
  2. Every failure is JSON + exit code 0. Answer with success: false and a reason.
  3. Logs go to stderr. stdout is for the result JSON. Print exception tracebacks to stderr too; don't swallow errors.
  4. Turn RCON off, and never put "rcon" in supported.
  5. Handle processes only by the pid the daemon gives you. Killing by executable name also ends other instances of the same game.
  6. Build paths with os.path.join; don't hardcode them.
  7. Verify field-name mappings. Check which game setting key each [[settings.fields]] name maps to, in both configure and import_settings.
  8. Tests. Keep unit tests in test_lifecycle.py, and pass the conformance kit (section 8 below).

8. Debugging Tips

  • The result is read from the last line of stdout, so send debug output to stderr. Printing anything after the result JSON breaks parsing.
  • You can run a function directly without the daemon. Add the Saba-chan repository's modules/ to PYTHONPATH so daemon_rcon can be found.
    echo '{"server_executable": "C:/Games/MyGame/GameServer.exe", "port": 7777}' | python lifecycle.py get_launch_command
    
  • Check module.toml with the same parser the daemon uses: saba-core module validate <module folder>
  • Conformance kit: pytest conformance --module path/to/mygame --shared-libs path/to/saba-chan/modules (in the saba-chan-modules repository)
  • If progress doesn't show up in the GUI, check that the stderr line starts with exactly PROGRESS: and that the JSON after it is valid.
  • After editing a module, also apply the change under modules\<name>\ in the data folder so the daemon uses the latest code. The daemon caches the module list, so if you changed module.toml, have it reload with POST /api/modules/refresh.

Next: Extension Development · REST API Reference