# 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](#overview) 2. [Archive Format (.o2r)](#archive-format-o2r) 3. [manifest.json Reference](#manifestjson-reference) 4. [Signing and Integrity Verification](#signing-and-integrity-verification) - [How Verification Works](#how-verification-works) - [Generating a Key Pair](#generating-a-key-pair) - [Signing an Archive](#signing-an-archive) - [Trust Levels](#trust-levels) 5. [Writing a C Mod](#writing-a-c-mod) - [Project Structure](#project-structure) - [Entry and Exit Points](#entry-and-exit-points) - [The Build Pipeline (Amalgamation)](#the-build-pipeline-amalgamation) - [build.gen](#buildgen) 6. [C API Reference](#c-api-reference) - [mod.h Macros](#modh-macros) - [Memory Allocation](#memory-allocation) - [Calling Functions from Other Mods](#calling-functions-from-other-mods) 7. [Event System](#event-system) - [Defining Events](#defining-events) - [Registering and Unregistering Listeners](#registering-and-unregistering-listeners) - [Event Priority](#event-priority) - [Cancellable Events](#cancellable-events) 8. [Dependencies Between Mods](#dependencies-between-mods) 9. [Pre-compiled Binaries](#pre-compiled-binaries) 10. [Ghostship APIs](#ghostship-apis) - [UI API](#ui-api) - [Achievements API](#achievements-api) - [Permissions API](#permissions-api) - [Relay API](#relay-api) --- ## 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)](https://bellard.org/tcc/) 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 └── # 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`. ```json { "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](#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](#trust-levels), but `IsSigned()` and `IsChecksumValid()` will both return `false`. ### Generating a Key Pair Use `tools/generate_keys.py` to create an Ed25519 key pair: ```sh # 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`: ```sh # 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`: ```c #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 (``, 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 `` 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`: ```c // 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`: ```c // 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: ```c // 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: ```c REGISTER_EVENT(MySimpleEvent); REGISTER_EVENT(MyDataEvent); ``` ### Registering and Unregistering Listeners ```c #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`: ```c 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: ```json { "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: ```json { "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 ```c 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: ```c 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 ```c #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 ```c 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 ```c #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 ```c 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 ```c #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 ```c 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 ```c #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.