Files

782 lines
27 KiB
Markdown
Raw Permalink Normal View History

2026-05-29 02:23:55 -06:00
# 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
└── <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`.
```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 (`<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`:
```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.