Files
2026-05-29 02:23:55 -06:00

27 KiB

Scripting System

This document covers the C-based mod scripting system — how archives are structured, how the manifest is parsed, how signing and integrity verification work, and how to write a mod using the event system.


Table of Contents

  1. Overview
  2. Archive Format (.o2r)
  3. manifest.json Reference
  4. Signing and Integrity Verification
  5. Writing a C Mod
  6. C API Reference
  7. Event System
  8. Dependencies Between Mods
  9. Pre-compiled Binaries
  10. Ghostship APIs

Overview

Mods are packaged as .o2r files, which are standard ZIP archives containing game assets, a manifest.json metadata file, and optionally C source files or pre-compiled shared libraries. When the engine loads a mod, the scripting system JIT-compiles the C sources using TCC (Tiny C Compiler) or loads the pre-built binary for the current platform, then calls the mod's ModInit entry point.


Archive Format (.o2r)

An .o2r file is a ZIP archive. Plain .zip files are also accepted. The internal layout is:

my-mod.o2r
├── manifest.json          # Required: mod metadata
├── build.gen              # Optional: list of C source files to JIT-compile
├── dist/
│   └── mod.c              # Amalgamated C source (referenced by build.gen)
├── binaries/
│   ├── linux_x64.so       # Optional pre-compiled binaries per platform
│   ├── windows_x64.dll
│   └── darwin.dylib
└── <game assets ...>      # Any other resource files the mod needs

manifest.json and build.gen are the only files the loader looks for by name. Everything else is up to the mod author.


manifest.json Reference

The manifest.json file lives at the root of the archive. All fields are optional except name.

{
    "name": "My Mod",
    "version": "1.0.0",
    "author": "Your Name",
    "description": "A short description of what this mod does.",
    "website": "https://example.com",
    "license": "MIT",
    "icon": "icon.png",
    "code_version": 1,
    "game_version": 4294967295,
    "main": "build.gen",
    "binaries": {
        "linux_x64": "binaries/linux_x64.so",
        "windows_x64": "binaries/windows_x64.dll",
        "darwin": "binaries/darwin.dylib"
    },
    "dependencies": ["other-mod-name"],
    "checksum": "",
    "signature": "",
    "public_key": ""
}

Fields

Field Type Default Description
name string required Display name of the mod.
version string "1.0" Mod version string.
author string "Unknown" Author name. Used in keystore trust prompts.
description string "No description provided." Short human-readable description.
website string "" Homepage or repository URL.
license string "All rights reserved" License identifier (e.g. "MIT", "GPL-3.0").
icon string "" Path inside the archive to an icon image.
code_version integer 1 Must match the engine's compiled code version. Mismatches prevent loading.
game_version integer 0xFFFFFFFF Required game version. 0xFFFFFFFF means any version is accepted.
main string "" Path to the build.gen file listing C sources to JIT-compile.
binaries object {} Map of platform key → path to a pre-compiled shared library inside the archive. See Pre-compiled Binaries for platform keys.
dependencies array of strings [] Names of other mods this mod depends on. They will be initialized before this mod.
checksum string "" BLAKE2b-512 hex digest injected by tools/sign.py. Do not set manually.
signature string "" Ed25519 hex signature injected by tools/sign.py. Do not set manually.
public_key string "" Author's Ed25519 raw public key (hex) injected by tools/sign.py. Do not set manually.

Note on code_version: The engine enforces that code_version in the manifest matches the version it was compiled against. If they differ, the mod will not be loaded. Increment this value when your mod's compiled interface changes.


Signing and Integrity Verification

How Verification Works

Archive validation is performed in Archive::Validate() and proceeds in two stages:

1. Checksum verification

A BLAKE2b-512 digest is computed over all non-manifest files in the archive, processed in sorted path order. Both the file path and the file contents are fed into the hash. The result is compared against the checksum field in manifest.json. If they do not match, the archive is rejected.

2. Signature verification

The raw BLAKE2b digest bytes are verified against the Ed25519 signature using the public_key from the manifest. The engine checks the public key against its internal Keystore (a set of trusted Ed25519 public keys). If the key is not already trusted:

  • If an UntrustedArchiveHandler callback is registered by the host application, the user is prompted to trust the key. On approval, the key is added to the keystore for future sessions.
  • If no handler is registered, the archive is rejected.

System keys (hard-coded trusted publisher keys bundled with the engine) are always accepted without a prompt.

An archive that has no checksum field is treated as unsigned. It can still be loaded depending on the configured Trust Level, but IsSigned() and IsChecksumValid() will both return false.

Generating a Key Pair

Use tools/generate_keys.py to create an Ed25519 key pair:

# Install the dependency first
pip install cryptography

# Generate keys (no passphrase)
python tools/generate_keys.py

# The script writes:
#   private_key.pem   — keep this secret
#   public_key.pem    — can be distributed

To protect the private key with a passphrase, edit the script and pass a password string to generate_keypair(password="your-passphrase").

Signing an Archive

Use tools/sign.py to sign a built .o2r / .zip:

# Sign without passphrase
python tools/sign.py my-mod.o2r private_key.pem

# Sign with passphrase
python tools/sign.py my-mod.o2r private_key.pem

The script:

  1. Computes a BLAKE2b-512 checksum over all non-manifest files (sorted by path).
  2. Signs the raw checksum bytes with the Ed25519 private key.
  3. Injects checksum, signature, and public_key (all hex-encoded) into manifest.json inside the zip in-place.

You must re-sign the archive any time you change its contents.

Trust Levels

The engine exposes a SafeLevel setting that controls how unsigned or untrusted archives are handled:

Level Behavior
DISABLE_SCRIPTS No scripts are loaded at all.
ONLY_TRUSTED_SCRIPTS Only archives with a valid checksum and a known-trusted signature are loaded. Unsigned archives are rejected.
WARN_UNTRUSTED_SCRIPTS (default) Unsigned archives log a warning but still load.
ALLOW_ALL_SCRIPTS All archives load silently regardless of signing status.

Writing a C Mod

Project Structure

The recommended layout for a mod project:

my-mod/
├── include/
│   └── mod.h              # Core macros (MOD_INIT, MOD_EXIT, HM_API)
├── src/
│   └── main.c             # Your mod logic
├── manifest.json          # Mod metadata
└── CMakeLists.txt         # Build configuration (amalgamation pipeline)

After building, the output goes to:

output/
├── dist/
│   └── main.c             # Amalgamated single-file output
├── build.gen              # Auto-generated list of output paths
└── manifest.json          # Your manifest (copied here for packaging)

Package the contents of output/ into a zip and rename it to .o2r.

Entry and Exit Points

Every mod must export two functions. Use the macros from mod.h:

#include "mod.h"

MOD_INIT() {
    // Called once when the mod is loaded.
    // Register event listeners here.
}

MOD_EXIT() {
    // Called once when the mod is unloaded or the engine shuts down.
    // Unregister listeners and free resources here.
}

MOD_INIT expands to HM_API void ModInit and MOD_EXIT to HM_API void ModExit. HM_API resolves to __declspec(dllexport) on Windows and __attribute__((visibility("default"))) on other platforms, ensuring the dynamic linker can find the symbols.

The Build Pipeline (Amalgamation)

Because mods are JIT-compiled by TCC at runtime, they must be packaged as self-contained C translation units — no separate object files or link steps are available at runtime.

The build system runs tools/merge.py on each .c file in src/. merge.py recursively inlines all #include directives that resolve to local header files, producing a single amalgamated .c file per source file in output/dist/. System headers (<stdio.h>, etc.) are left as-is.

Important: Because all source files ultimately share a single global namespace when loaded, any helper function that should not be visible outside your file must be declared static. Failing to do so risks symbol collisions with the engine or other mods.

build.gen

build.gen is a plain text file listing the paths to amalgamated .c files to compile, one per line:

dist/main.c
dist/utils.c

Paths are relative to the root of the archive. The main field in manifest.json points to this file.


C API Reference

mod.h Macros

Macro / Symbol Expands To Description
HM_API __declspec(dllexport) / __attribute__((visibility("default"))) Marks a symbol as exported from the shared library.
MOD_INIT() HM_API void ModInit Declares the mod initialization entry point.
MOD_EXIT() HM_API void ModExit Declares the mod shutdown entry point.
CALL_FUNC(mod, func, ...) ((func##Func)ScriptGetFunction(mod, func##Symbol))(...) Calls an exported function from another loaded mod by name.
malloc(size) GameEngine_Malloc(size) Routes heap allocation through the engine allocator.
free(ptr) GameEngine_Free(ptr) Routes heap deallocation through the engine allocator.

Note: malloc and free are redefined by mod.h to use engine-managed allocators. Do not include <stdlib.h> after including mod.h, or the redefinition will conflict.

Memory Allocation

Always use malloc/free as provided by mod.h. These route through GameEngine_Malloc and GameEngine_Free, ensuring allocations are tracked by the engine and freed correctly when the mod is unloaded.

Calling Functions from Other Mods

To call an exported function from another mod or from the engine's scripting bridge, use ScriptGetFunction:

// Prototype of the function you want to call
typedef void (*MyFuncType)(int value);

// Look up the function from module "other-mod"
MyFuncType fn = (MyFuncType)ScriptGetFunction("other-mod", "MyExportedFunction");
if (fn != NULL) {
    fn(42);
}

Or use the CALL_FUNC macro if you define the function type and symbol name with the expected naming convention.


Event System

The event system lets mods hook into points in the engine and game loop without modifying source code. Events are identified by a global EventID that is registered at startup and dispatched via EventSystemCallEvent.

Defining Events

Events are defined using the DEFINE_EVENT macro from include/ship/events/EventTypes.h. This generates a struct type and declares its global EventID:

// An event with no extra data
DEFINE_EVENT(MySimpleEvent);

// An event that carries additional fields
DEFINE_EVENT(MyDataEvent,
    int someValue;
    float otherValue;
);

The first member of every generated struct is always IEvent Event, which holds the Cancelled flag. When dispatching, use the corresponding CALL_EVENT or CALL_CANCELLABLE_EVENT macros:

// Fire a simple event
CALL_EVENT(MySimpleEvent);

// Fire a data event
CALL_EVENT(MyDataEvent, .someValue = 42, .otherValue = 3.14f);

// Fire a cancellable event — code after the macro only runs if not cancelled
CALL_CANCELLABLE_EVENT(MyDataEvent, .someValue = 1, .otherValue = 0.0f) {
    // only reached if no listener cancelled the event
}

Events must be registered once at engine startup using REGISTER_EVENT before they can be dispatched or listened to:

REGISTER_EVENT(MySimpleEvent);
REGISTER_EVENT(MyDataEvent);

Registering and Unregistering Listeners

#include "mod.h"
// Include the header that declares the events you want to listen to
#include "your/events/header.h"

ListenerID gMyListenerID;

void OnMyEvent(IEvent* event) {
    // Cast to the concrete event type to access its fields
    MyDataEvent* e = (MyDataEvent*)event;
    // ...
}

MOD_INIT() {
    gMyListenerID = REGISTER_LISTENER(MyDataEvent, EVENT_PRIORITY_NORMAL, OnMyEvent);
}

MOD_EXIT() {
    UNREGISTER_LISTENER(MyDataEvent, gMyListenerID);
}

Always store the returned ListenerID globally so you can unregister in MOD_EXIT. Failing to unregister will cause a use-after-free when the mod is unloaded.

Macros:

Macro Description
REGISTER_LISTENER(eventType, priority, callback) Registers callback for eventType at priority. Returns a ListenerID.
UNREGISTER_LISTENER(eventType, listenerID) Removes the listener identified by listenerID from eventType.

Event Priority

Listeners for the same event are called in descending priority order.

Priority Description
EVENT_PRIORITY_LOW Called after normal-priority listeners.
EVENT_PRIORITY_NORMAL Default priority for most use cases.
EVENT_PRIORITY_HIGH Called before normal-priority listeners.

Cancellable Events

Events fired with CALL_CANCELLABLE_EVENT or CALL_CANCELLABLE_RETURN_EVENT can be cancelled by any listener. To cancel, set Cancelled on the base IEvent:

void OnMyEvent(IEvent* event) {
    MyDataEvent* e = (MyDataEvent*)event;
    if (/* some condition */) {
        e->Event.Cancelled = true;
    }
}

Once cancelled:

  • Remaining lower-priority listeners still receive the event but can check event->Cancelled.
  • The engine code block gated by CALL_CANCELLABLE_EVENT is skipped.
  • CALL_CANCELLABLE_RETURN_EVENT causes an immediate return in the calling function.

Dependencies Between Mods

List the name values of mods your mod requires in the dependencies array:

{
    "name": "my-mod",
    "dependencies": ["base-library-mod"]
}

The scripting system performs a topological sort (Kahn's algorithm) across all loaded mods before calling ModInit, ensuring dependencies are always initialized first and uninitialized last.

Circular dependencies are detected and will prevent loading.


Pre-compiled Binaries

If you want to ship a pre-compiled shared library instead of (or in addition to) C sources, populate the binaries map with platform keys pointing to paths inside the archive:

{
    "binaries": {
        "linux_x64":     "binaries/linux_x64.so",
        "linux_arm64":   "binaries/linux_arm64.so",
        "windows_x64":   "binaries/windows_x64.dll",
        "windows_arm64": "binaries/windows_arm64.dll",
        "darwin":        "binaries/darwin.dylib",
        "android":       "binaries/android.so",
        "ios":           "binaries/ios.dylib"
    }
}

The loader checks binaries before attempting JIT compilation. If a matching entry is found for the current platform, the pre-built library is extracted to a temporary file and loaded directly. If no match is found and main is set, the C sources are JIT-compiled instead.

Platform Keys

Key Platform
windows_x64 Windows, x86-64
windows_arm64 Windows, ARM64
darwin macOS (any arch)
linux_x64 Linux, x86-64
linux_x86 Linux, x86 (32-bit)
linux_arm64 Linux, ARM64 / AArch64
linux_generic Linux, other architectures
android Android
ios iOS
bsd BSD variants

Ghostship APIs

The following C APIs are provided by Ghostship (not libultraship) and are available to mods running inside the Ghostship port. Include the relevant header from src/port/api/ and declare it extern in your mod, or use the amalgamation pipeline to inline it.

All functions use C linkage and are safe to call from C99/C11 source files.


UI API

Header: port/api/ui.h

Lets mods add entries and widgets to the Ghostship settings menu.

Types

typedef enum {
    C_WIDGET_CHECKBOX,
    C_WIDGET_COMBOBOX,
    C_WIDGET_SLIDER_INT,
    C_WIDGET_SLIDER_FLOAT,
    C_WIDGET_CVAR_CHECKBOX,
    C_WIDGET_CVAR_COMBOBOX,
    C_WIDGET_CVAR_SLIDER_INT,
    C_WIDGET_CVAR_SLIDER_FLOAT,
    C_WIDGET_BUTTON,
    C_WIDGET_INPUT,
    C_WIDGET_CVAR_INPUT,
    C_WIDGET_CVAR_COLOR_PICKER,
    C_WIDGET_COLOR_PICKER,
    C_WIDGET_SEPARATOR,
    C_WIDGET_SEPARATOR_TEXT,
    C_WIDGET_TEXT,
    // ...
} C_WidgetType;

C_WidgetConfig is a tagged union struct. Set type to select which opts member is active:

typedef struct {
    C_WidgetType  type;
    const char*   cvar;         // for CVAR_* types
    const char*   window_name;  // for WINDOW_BUTTON type
    bool          race_disable;
    bool          same_line;
    bool          hide_in_search;
    bool          is_hidden;
    void (*callback)(void);
    void (*pre_func)(void);
    void (*post_func)(void);
    union {
        C_ComboboxOpts    combo;
        C_SliderIntOpts   slider_int;
        C_SliderFloatOpts slider_float;
        C_CheckboxOpts    checkbox;
        C_TextOpts        text;
        C_ColorPickerOpts color_picker;
        C_ButtonOpts      generic;
    } opts;
} C_WidgetConfig;

Functions

Function Description
void C_AddSidebarEntry(const char* label, int column) Adds a sidebar section under Mods. column is 1 or 2.
void C_AddWidget(const char* sidebar, int column, const char* label, C_WidgetConfig* config) Adds a widget under a sidebar entry.
void C_RemoveSidebarEntry(const char* sidebarName) Removes a sidebar entry and all its widgets.

Example

#include "mod.h"
#include "port/api/ui.h"

static void on_toggle(void) { /* respond to checkbox change */ }

MOD_INIT() {
    C_WidgetConfig chk = {0};
    chk.type = C_WIDGET_CVAR_CHECKBOX;
    chk.cvar = "gMyMod.Enabled";
    chk.opts.checkbox.tooltip = "Enable my mod feature.";
    chk.opts.checkbox.default_val = true;
    chk.callback = on_toggle;

    C_AddSidebarEntry("My Mod", 1);
    C_AddWidget("My Mod", 1, "Enable Feature", &chk);
}

MOD_EXIT() {
    C_RemoveSidebarEntry("My Mod");
}

Achievements API

Header: port/api/achievements.h

Lets mods define and track custom achievements that appear alongside the built-in ones in the Achievements window.

Types

typedef enum {
    C_ACH_CATEGORY_NONE   = 0,
    C_ACH_CATEGORY_STARS  = 1 << 0,
    C_ACH_CATEGORY_CAPS   = 1 << 1,
    C_ACH_CATEGORY_LEVELS = 1 << 2,
    C_ACH_CATEGORY_BOSSES = 1 << 3,
    C_ACH_CATEGORY_DEATHS = 1 << 4,
    C_ACH_CATEGORY_EXTRAS = 1 << 5,
} C_AchievementCategory;

typedef struct {
    C_AchievementCategory  category;
    const char*            name;
    const char*            icon;         // texture path, e.g. "extras.my-icon"
    const char*            description;
    int32_t                maxProgress;  // goal value; use 1 for binary done/not-done
    const char**           dependencies; // NULL-terminated array of achievement IDs, or NULL
} C_AchievementDef;

Functions

Function Description
void C_RegisterAchievement(const char* id, const C_AchievementDef* def) Registers a new achievement. Duplicate IDs are silently ignored. Safe to call at any time after engine init.
void C_AchievementProgress(const char* id, int32_t amount) Increments progress on id by amount. Triggers the unlock notification when progress reaches maxProgress.
int32_t C_AchievementGetProgress(const char* id) Returns current progress. Returns 0 for unknown IDs.
int C_AchievementIsUnlocked(const char* id) Returns 1 if unlocked, 0 otherwise.

Example

#include "mod.h"
#include "port/api/achievements.h"
#include "port/events/Events.h"

static ListenerID gJumpListenerID;
static int32_t    gJumpCount = 0;

static void on_jump(IEvent* event) {
    (void)event;
    gJumpCount++;
    if (gJumpCount % 10 == 0) {
        C_AchievementProgress("MyMod.BigJumper", 10);
    }
}

MOD_INIT() {
    static const C_AchievementDef def = {
        .category    = C_ACH_CATEGORY_EXTRAS,
        .name        = "Big Jumper",
        .icon        = "extras.jumper",
        .description = "Jump 500 times.",
        .maxProgress = 500,
        .dependencies = NULL,
    };
    C_RegisterAchievement("MyMod.BigJumper", &def);

    gJumpListenerID = REGISTER_LISTENER(PlayerSetAction, EVENT_PRIORITY_NORMAL, on_jump);
}

MOD_EXIT() {
    UNREGISTER_LISTENER(PlayerSetAction, gJumpListenerID);
}

Note: Achievement progress is automatically saved alongside game saves. Always use a namespaced ID (e.g. "MyMod.SomeAchievement") to avoid collisions with built-in achievements.


Permissions API

Header: port/api/permissions.h

Lets mods request explicit user consent before performing sensitive operations. Permission decisions are persisted across sessions in permissions.json and are never writable through the CVar system.

Types

typedef enum {
    PERM_PENDING = 0,  // user has not been asked yet
    PERM_ALLOWED = 1,  // user granted access
    PERM_DENIED  = 2,  // user denied access
} C_PermissionState;

typedef void (*C_PermissionCallback)(void);

Functions

Function Description
void C_RegisterPermission(const char* key, const char* title, const char* message) Registers a permission key with the dialog text shown to the user. Idempotent — safe to call multiple times with the same key.
C_PermissionState C_GetPermission(const char* key) Returns the current persisted state for key.
void C_RequestPermission(const char* key, C_PermissionCallback on_allow, C_PermissionCallback on_deny) Shows the permission dialog if state is still PERM_PENDING. Either callback may be NULL. Does nothing if already decided.

Permission flow

First run:
  C_RegisterPermission  →  C_RequestPermission  →  [modal shown]
                                                       ├─ Allow → on_allow(), state saved as PERM_ALLOWED
                                                       └─ Deny  → on_deny(),  state saved as PERM_DENIED

Subsequent runs:
  C_GetPermission → PERM_ALLOWED or PERM_DENIED (no modal shown again)

Example

#include "mod.h"
#include "port/api/permissions.h"

static void on_allowed(void) {
    /* start the feature that requires permission */
}

MOD_INIT() {
    C_RegisterPermission(
        "mymods.telemetry",
        "My Mod — Telemetry Permission",
        "My Mod wants to send anonymous play-time data to the mod author.\n"
        "No personal information is collected. Allow?"
    );

    if (C_GetPermission("mymods.telemetry") == PERM_ALLOWED) {
        on_allowed();
    } else {
        C_RequestPermission("mymods.telemetry", on_allowed, NULL);
    }
}

Security note: The write path for permission decisions is internal to the engine and is only triggered by the user clicking a button in the modal. It is not reachable through CVarSetInteger or any other scripting API.


Relay API

Header: port/api/relay.h

Lets mods send and receive binary messages over the Satella WebSocket relay. Each channel is isolated — messages sent on one channel are only delivered to other subscribers of that same channel.

Before any data can flow, the user must grant permission through a modal prompt (backed by the Permissions system). The decision is persisted; the prompt only appears once per channel per installation.

Types

typedef enum {
    RELAY_PERM_PENDING = 0,
    RELAY_PERM_ALLOWED = 1,
    RELAY_PERM_DENIED  = 2,
} C_RelayPermission;

// Called when a message arrives on the channel.
// `data` is the raw payload; `size` is its byte length.
typedef void (*C_RelayCallback)(const char* channelId, const char* data, uint32_t size);

typedef struct {
    const char*     mod_name;    // shown in the permission dialog
    const char*     description; // one-sentence explanation shown to the user
    C_RelayCallback on_message;
} C_RelayChannelDef;

Functions

Function Description
void C_RegisterRelayChannel(const char* channelId, const C_RelayChannelDef* def) Registers a channel. Duplicate IDs are ignored. Safe to call from MOD_INIT.
int C_RelaySend(const char* channelId, const char* data, uint32_t size) Sends size bytes to all other subscribers of the channel. Returns 1 on success, 0 if not permitted, not connected, or validation failed.
C_RelayPermission C_RelayGetPermission(const char* channelId) Returns the current permission state for the channel.
void C_RelayRequestPermission(const char* channelId) Shows the permission modal if state is still RELAY_PERM_PENDING. No-op if already decided.

Channel ID rules

Channel IDs must be non-empty strings of at most 128 characters using only [a-zA-Z0-9._-]. Use reverse-domain notation to avoid collisions: "mymods.positions", "mymods.chat".

Send limits

C_RelaySend rejects payloads larger than 64 KB. Payloads of size 0 are also rejected.

Example

#include "mod.h"
#include "port/api/relay.h"

typedef struct { float x, y, z; } Vec3;

static void on_relay_message(const char* channel, const char* data, uint32_t size) {
    (void)channel;
    if (size < sizeof(Vec3)) {
        return;
    }
    Vec3 pos;
    memcpy(&pos, data, sizeof(Vec3));
    /* render ghost at pos.x, pos.y, pos.z */
}

MOD_INIT() {
    static const C_RelayChannelDef def = {
        .mod_name    = "Ghost Positions Mod",
        .description = "Syncs player positions with other connected players.",
        .on_message  = on_relay_message,
    };
    C_RegisterRelayChannel("mymods.positions", &def);
    /* Permission prompt is shown automatically on first run. */
}

/* Call this from your game-loop listener to broadcast the local player position. */
void SendPosition(float x, float y, float z) {
    Vec3 pos = { x, y, z };
    C_RelaySend("mymods.positions", (const char*)&pos, sizeof(pos));
}

Note: The engine handles connection, reconnection, and subscriber cleanup automatically. Mods only need to register their channel and call C_RelaySend when they have data to send.