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_pattern is used for syntax highlighting in the GUI console.
  • [protocols] — Decides which channel the daemon uses to send commands. Choose between rcon, rest, and stdin.
  • [config] — Module defaults. The user's instances.json.settings overrides these.
  • [install].requires_extensions — Extensions that must be ensured at install time. Palworld needs SteamCMD + UE4-INI.
  • [credential_map] — Maps keys returned from get_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, or command do not exist. State detection is performed by the daemon directly, using [config].process_name and 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:

  1. Spawn a process with the portable Python executable + the module folder's lifecycle.py as arguments.
  2. Inject environment variables:
    • DAEMON_API_URL=http://127.0.0.1:57474
    • PYTHONIOENCODING=utf-8
    • PYTHONUNBUFFERED=1
    • PYTHONPATH=<modules_dir>;<extensions_dir>
    • SABA_ENABLED_EXTENSIONS=docker;music;steamcmd (example)
  3. Send a single JSON request on stdin:
    { "action": "get_launch_command", "config": { ... } }
    
  4. The last line on stdout (a JSON value) is taken as the result.
  5. stderr is treated as logs. However, lines prefixed with PROGRESS: are parsed as progress events and surface in the response of GET /api/provision-progress/:name.
  6. 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

ModuleDefault portProtocolRequired extensionsNotes
minecraft25565 (+ 25575 RCON)RCON (default) / stdinnoneRequires the portable JRE
palworld8211REST APIsteamcmd, ue4-iniUses credential_map to map rest_password ↔ AdminPassword
zomboid16261 (+ 27015 RCON)RCON / stdinsteamcmdstop_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.

  1. No dependency on daemon code. Use only the config, environment variables, and APIs the daemon provides.
  2. No hardcoded paths. Use os.path.join exclusively. Do not mix slashes and backslashes.
  3. Do not swallow errors. Print exceptions with their full traceback to stderr — the daemon collects it.
  4. Consistent encoding. JSON uses utf-8-sig; for TOML, strip BOM manually.
  5. TDD. Add at least minimal unit tests in test_lifecycle.py. CI runs them with pytest.
  6. Verify field-name mappings. Cross-check, in both directions, which fields in module.toml flow 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.

Next: Extension Development · REST API Reference