mirror of
https://github.com/TwilitRealm/dusklight
synced 2026-07-09 20:41:29 -04:00
451 lines
17 KiB
Markdown
451 lines
17 KiB
Markdown
# Dusklight Mod API
|
|
|
|
Mods are distributed as `.dusk` files: zip archives containing a `mod.json` manifest and, optionally, compiled code
|
|
libraries and resources.
|
|
|
|
Everything a mod does goes through **services**: small, versioned C APIs. Dusklight provides built-in services, and mods
|
|
can define their own to talk to each other.
|
|
|
|
## Table of Contents
|
|
|
|
1. [Getting Started](#getting-started)
|
|
2. [mod.json](#modjson)
|
|
3. [Anatomy of a Code Mod](#anatomy-of-a-code-mod)
|
|
4. [Services](#services)
|
|
5. [Built-in Services](#built-in-services)
|
|
6. [Asset Overlays](#asset-overlays)
|
|
7. [Runtime Lifecycle](#runtime-lifecycle)
|
|
8. [Error Handling](#error-handling)
|
|
9. [Advanced: Exporting Services](#advanced-exporting-services)
|
|
|
|
---
|
|
|
|
## Getting Started
|
|
|
|
Fork the [mod template](../tools/mod_template/), a self-contained CMake project that uses the Dusklight mod SDK.
|
|
|
|
```
|
|
my_mod/
|
|
├── CMakeLists.txt
|
|
├── mod.json
|
|
├── src/mod.cpp
|
|
├── res/ (optional bundled resources)
|
|
├── overlay/ (optional game file overrides)
|
|
└── textures/ (optional texture replacements)
|
|
```
|
|
|
|
**CMakeLists.txt:**
|
|
|
|
```cmake
|
|
cmake_minimum_required(VERSION 3.25)
|
|
project(my_mod CXX)
|
|
|
|
set(DUSKLIGHT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/dusklight" CACHE PATH "Path to dusklight source root")
|
|
add_subdirectory("${DUSKLIGHT_DIR}/sdk" dusklight-sdk EXCLUDE_FROM_ALL)
|
|
|
|
add_mod(my_mod
|
|
SOURCES src/mod.cpp
|
|
MOD_JSON mod.json
|
|
RES_DIR res # optional
|
|
OVERLAY_DIR overlay # optional
|
|
TEXTURES_DIR textures # optional
|
|
)
|
|
```
|
|
|
|
Building produces `my_mod.dusk` in `build/<preset>/mods/` (configurable via the `DUSK_MODS_OUTPUT_DIR` cache variable).
|
|
Copy it into the game's mods folder and launch:
|
|
|
|
- Windows: `%APPDATA%\TwilitRealm\Dusklight\mods`
|
|
- Linux: `~/.local/share/TwilitRealm/Dusklight/mods`
|
|
- macOS: `~/Library/Application Support/TwilitRealm/Dusklight/mods`
|
|
|
|
You can also pass `--mods <dir>` on the command line, which is handy during development. Mods will load from there
|
|
instead of the user directory above.
|
|
|
|
---
|
|
|
|
## mod.json
|
|
|
|
```json
|
|
{
|
|
"id": "com.example.my_mod",
|
|
"name": "My Mod",
|
|
"version": "1.0.0",
|
|
"author": "Your Name",
|
|
"description": "A short description shown in the mod manager.",
|
|
"icon": "res/my_icon.png",
|
|
"banner": "res/my_banner.png"
|
|
}
|
|
```
|
|
|
|
`id` is required: a unique, stable identifier (reverse-DNS style; periods, underscores, and alphanumerics). Everything
|
|
else is optional but recommended.
|
|
|
|
`icon` and `banner` are bundle-relative paths to PNG images for the in-game mod manager: the square icon (e.g. 512x512),
|
|
the banner (roughly 3.5:1).
|
|
Both keys are optional; if omitted, `res/icon.png` and `res/banner.png` are used automatically when present.
|
|
|
|
---
|
|
|
|
## Anatomy of a Code Mod
|
|
|
|
```cpp
|
|
#include "mods/service.hpp"
|
|
#include "mods/svc/log.h"
|
|
|
|
DEFINE_MOD(); // once, in exactly one translation unit
|
|
IMPORT_SERVICE(LogService, svc_log); // resolved by the loader before mod_initialize
|
|
|
|
extern "C" {
|
|
|
|
MOD_EXPORT ModResult mod_initialize(ModError* error) {
|
|
svc_log->info(mod_ctx, "hello from my_mod");
|
|
return MOD_OK;
|
|
}
|
|
|
|
MOD_EXPORT ModResult mod_update(ModError* error) { // called every frame
|
|
return MOD_OK;
|
|
}
|
|
|
|
MOD_EXPORT ModResult mod_shutdown(ModError* error) {
|
|
return MOD_OK;
|
|
}
|
|
}
|
|
```
|
|
|
|
All three lifecycle exports are required. `mod_ctx` is your mod's identity token, set by the loader before
|
|
`mod_initialize` runs. Pass it as the first argument to every service call.
|
|
|
|
---
|
|
|
|
## Services
|
|
|
|
A service is a struct of C function pointers with a version header. You declare what you use at file scope, and the
|
|
loader resolves it before your mod initializes:
|
|
|
|
```cpp
|
|
IMPORT_SERVICE(LogService, svc_log); // required, any minor version
|
|
IMPORT_SERVICE_VERSION(LogService, svc_log, 2); // required, minor version >= 2
|
|
IMPORT_OPTIONAL_SERVICE(SomeService, svc_maybe); // may be null
|
|
```
|
|
|
|
The rules (see `include/mods/api.h` for the full contract):
|
|
|
|
- **A required import is guaranteed valid.** If the service is missing or too old, the mod fails to load with a clear
|
|
error. No need to null check at call sites.
|
|
- **Anything at or below the minor version you imported can be called unconditionally.**
|
|
- Optional imports may be null; check once in `mod_initialize`.
|
|
- Fields newer than your imported minor must be gated behind `SERVICE_HAS(service, ServiceType, field)` plus a null
|
|
check.
|
|
|
|
Service versions follow one rule: a **major** bump is a breaking change (treated as a different service entirely), a *
|
|
*minor** bump only appends functions.
|
|
|
|
---
|
|
|
|
## Built-in Services
|
|
|
|
### LogService (`mods/svc/log.h`)
|
|
|
|
```cpp
|
|
IMPORT_SERVICE(LogService, svc_log);
|
|
|
|
svc_log->info(mod_ctx, "spawned the thing");
|
|
svc_log->warn(mod_ctx, "that looks wrong");
|
|
svc_log->error(mod_ctx, "very bad");
|
|
svc_log->write(mod_ctx, LOG_LEVEL_DEBUG, "verbose details");
|
|
```
|
|
|
|
Messages appear in the console prefixed with your mod ID. Messages are plain strings: use `snprintf` or `fmt::format`
|
|
for formatting.
|
|
|
|
### ResourceService (`mods/svc/resource.h`)
|
|
|
|
Loads files from the `res/` tree of your `.dusk` archive. Paths are relative to `res/` (pass `"config.txt"`, not
|
|
`"res/config.txt"`); absolute paths and `..` are rejected.
|
|
|
|
```cpp
|
|
IMPORT_SERVICE(ResourceService, svc_resource);
|
|
|
|
ResourceBuffer buf = RESOURCE_BUFFER_INIT;
|
|
if (svc_resource->load(mod_ctx, "config.txt", &buf) == MOD_OK) {
|
|
// buf.data / buf.size
|
|
svc_resource->free(mod_ctx, &buf);
|
|
}
|
|
```
|
|
|
|
Missing files return `MOD_UNAVAILABLE`. Always `free` what you `load`. For writable storage, use the directory from
|
|
`svc_host->mod_dir(mod_ctx)`.
|
|
|
|
### HostService (`mods/svc/host.h`)
|
|
|
|
Mod metadata and runtime interaction with the loader:
|
|
|
|
```cpp
|
|
IMPORT_SERVICE(HostService, svc_host);
|
|
|
|
const char* id = svc_host->mod_id(mod_ctx);
|
|
const char* dir = svc_host->mod_dir(mod_ctx); // writable per-mod directory
|
|
svc_host->fail(mod_ctx, MOD_ERROR, "something unrecoverable happened"); // disables the mod
|
|
```
|
|
|
|
`get_service`/`publish_service` provide dynamic service lookup; see [Advanced](#advanced-exporting-services).
|
|
|
|
**Lifecycle watches.** If your mod provides a service that hands out per-caller state (registrations, callbacks,
|
|
handles), watch other mods' lifecycle and drop what you hold for a mod when it detaches.
|
|
|
|
```cpp
|
|
IMPORT_SERVICE_VERSION(HostService, svc_host, 1);
|
|
|
|
void on_mod_lifecycle(ModContext* ctx, ModContext* subject, const char* subject_id,
|
|
ModLifecycleEvent event, void* user_data) {
|
|
if (event == MOD_LIFECYCLE_DETACHED) {
|
|
drop_state_for(subject); // same ModContext* the subject passed into your service
|
|
}
|
|
}
|
|
|
|
uint64_t watch = 0;
|
|
svc_host->watch_mod_lifecycle(mod_ctx, on_mod_lifecycle, nullptr, &watch);
|
|
```
|
|
|
|
`MOD_LIFECYCLE_DETACHED` fires on the game thread at a lifecycle safe point, after the subject's `mod_shutdown` ran and
|
|
every service dropped its state. For your own mod's teardown, use `mod_shutdown` instead.
|
|
|
|
### OverlayService (`mods/svc/overlay.h`)
|
|
|
|
Registers DVD file overlays at runtime. The dynamic counterpart to the static `overlay/` directory (
|
|
see [Asset Overlays](#asset-overlays)). Overlay a disc path with a file from your bundle, or with a caller-owned
|
|
buffer (copied on registration):
|
|
|
|
```cpp
|
|
IMPORT_SERVICE(OverlayService, svc_overlay);
|
|
|
|
OverlayHandle handle = 0;
|
|
svc_overlay->add_file(mod_ctx, "/res/Msgus.arc", "res/replacement.arc", &handle);
|
|
svc_overlay->add_buffer(mod_ctx, "/generated.txt", data, size, nullptr);
|
|
svc_overlay->remove(mod_ctx, handle);
|
|
```
|
|
|
|
`disc_path` must be absolute (leading `/`) and is matched against the disc case-insensitively. Paths that don't exist on
|
|
the disc are added as new files. Changes are applied at the next frame boundary, and data the game already read stays in
|
|
memory until the file is re-read (sometimes a scene reload, sometimes never; a restart may be required).
|
|
|
|
See [Asset Overlays](#asset-overlays) for priority and conflict handling.
|
|
|
|
### TextureService (`mods/svc/texture.h`)
|
|
|
|
Registers texture replacements at runtime. The dynamic counterpart to the static `textures/` directory (
|
|
see [Asset Overlays](#asset-overlays)). Two forms: raw texel data with an explicit key, or an encoded `.dds`/`.png` from
|
|
your bundle whose filename encodes the key:
|
|
|
|
```cpp
|
|
IMPORT_SERVICE(TextureService, svc_texture);
|
|
|
|
// Encoded file; filename follows the replacement naming convention.
|
|
TextureReplacementHandle handle = 0;
|
|
svc_texture->register_file(mod_ctx, "res/tex1_32x32_$_6.png", &handle);
|
|
|
|
// Raw data: match by texel-data pointer or by content hash (TEXTURE_KEY_SOURCE).
|
|
TextureKey key = TEXTURE_KEY_INIT;
|
|
key.kind = TEXTURE_KEY_POINTER;
|
|
key.pointer = someTexObj.data;
|
|
TextureData data = TEXTURE_DATA_INIT;
|
|
data.data = pixels; data.size = pixelsSize;
|
|
data.width = 32; data.height = 32; data.gx_format = GX_TF_RGBA8_PC;
|
|
svc_texture->register_data(mod_ctx, &key, &data, nullptr);
|
|
|
|
svc_texture->unregister(mod_ctx, handle);
|
|
```
|
|
|
|
Filenames use the same convention as the user's `texture_replacements` directory:
|
|
`tex1_{w}x{h}_{texhash}[_{tluthash}]_{fmt}.dds|.png`, where hashes may be `$` (wildcard). `_mipN` sidecar files next to
|
|
a registered file are picked up automatically. Files are decoded lazily on first use by the renderer; raw data is copied
|
|
at registration. Registrations follow your mod's lifecycle.
|
|
|
|
See [Asset Overlays](#asset-overlays) for priority and conflict handling.
|
|
|
|
### ConfigService (`mods/svc/config.h`)
|
|
|
|
Persistent, mod-scoped configuration variables. Each var is stored in the user's `config.json` under
|
|
`mod.<escaped mod id>.<name>` (escaping: `.` → `_`, `_` → `__`, so `com.example.my_mod` becomes `com_example_my__mod`),
|
|
next to the host's own settings:
|
|
|
|
```cpp
|
|
IMPORT_SERVICE(ConfigService, svc_config);
|
|
|
|
ConfigVarDesc desc = CONFIG_VAR_DESC_INIT;
|
|
desc.name = "speedMultiplier"; // 1-64 chars from [A-Za-z0-9_-]; "enabled" is reserved
|
|
desc.type = CONFIG_VAR_FLOAT;
|
|
desc.default_float = 1.0;
|
|
ConfigVarHandle var = 0;
|
|
svc_config->register_var(mod_ctx, &desc, &var);
|
|
|
|
double speed = 1.0;
|
|
svc_config->get_float(mod_ctx, var, &speed);
|
|
svc_config->set_float(mod_ctx, var, 2.0);
|
|
|
|
// Optional: get notified when the value changes.
|
|
void on_speed_changed(ModContext* ctx, ConfigVarHandle var, const ConfigVarValue* value,
|
|
const ConfigVarValue* previous, void* user_data) {
|
|
/* value->float_value is the new value, previous->float_value the old one */
|
|
}
|
|
svc_config->subscribe(mod_ctx, var, on_speed_changed, nullptr, nullptr);
|
|
```
|
|
|
|
Types: `CONFIG_VAR_BOOL` (`bool`), `CONFIG_VAR_INT` (`int64_t`), `CONFIG_VAR_FLOAT` (`double`), `CONFIG_VAR_STRING`
|
|
(UTF-8; `get_string` copies into a caller buffer, pass a `NULL` buffer with size 0 to query the length). Accessors are
|
|
typed and must match the registration.
|
|
|
|
Change callbacks fire on the game thread whenever the value changes at runtime (your own `set_*` calls included).
|
|
Writes that store the same value are silent. Values applied from `config.json` or `--cvar` at registration do
|
|
**not** fire callbacks; read the value after `register_var` for the starting state.
|
|
|
|
---
|
|
|
|
## Asset Overlays
|
|
|
|
Files placed under `overlay/` in the `.dusk` archive override game files at the corresponding path. For example,
|
|
`overlay/res/Stage/...` shadows that file on the game disc image. This requires no code: an archive with just
|
|
`mod.json` and `overlay/` is a complete mod.
|
|
|
|
Files placed under `textures/` register as texture replacements the same way. Filenames follow the replacement naming
|
|
convention (`tex1_{w}x{h}_{texhash}[_{tluthash}]_{fmt}.dds|.png`, `$` as a hash wildcard). Subdirectories are scanned
|
|
recursively; only the filename needs to match.
|
|
|
|
Both follow the mod's lifecycle: disabling the mod removes its overrides (files revert to the disc contents on their
|
|
next open; added files stop existing), and reloading serves the new bundle's content. Game data the engine already read
|
|
stays as-is until it is loaded again, which may require a scene reload or a full restart. Texture replacements usually
|
|
take effect immediately.
|
|
|
|
If multiple sources replace the same file or texture, the last one wins: runtime registrations override static
|
|
`textures/` or `overlay/` files, and later-loaded mods override earlier ones. Cross-mod conflicts log warnings.
|
|
**All** mod-provided texture replacements override the user's `texture_replacements/`.
|
|
|
|
To configure overlays and texture replacements at runtime instead, see [OverlayService](#overlayservice-modssvcoverlayh)
|
|
and [TextureService](#textureservice-modssvctextureh).
|
|
|
|
---
|
|
|
|
## Runtime Lifecycle
|
|
|
|
Mods can be disabled, re-enabled, and reloaded at runtime without restarting the game (the enabled state persists as the
|
|
`mod.<escaped id>.enabled` config var). Write your mod assuming this happens:
|
|
|
|
- **Disable** calls `mod_shutdown`, removes your services, overlays, and texture replacements (both static and
|
|
runtime-registered), and unloads your library.
|
|
- **Enable** and **Reload** load a *fresh copy* of your library, imports are re-resolved, and `mod_initialize` runs
|
|
again. You never see a second `mod_initialize` on the same image, so just make `mod_shutdown` release anything the
|
|
loader doesn't manage for you (threads, files, game-side state you mutated).
|
|
- **Reload** additionally re-reads the `.dusk` from disk, picking up a rebuilt library and changed assets. This is the
|
|
fast iteration loop during development: rebuild, click Reload.
|
|
|
|
**Dependents restart too.** Disabling or reloading a mod that exports services shuts down the mods importing them
|
|
first (in reverse dependency order) and brings them back afterward. A mod whose *required* provider is disabled stays
|
|
suspended and resumes automatically when the provider returns. Mods with an *optional* import of a disabled provider
|
|
restart with that import null.
|
|
|
|
---
|
|
|
|
## Error Handling
|
|
|
|
Service calls report failure through `ModResult` return values (`MOD_OK`, `MOD_UNAVAILABLE`,
|
|
`MOD_INVALID_ARGUMENT`, ...). Lifecycle exports additionally receive a `ModError*`: fill it (e.g. with
|
|
`dusk::mods::set_error(error, code, "message")`) and return the code, and the loader disables the mod and shows the
|
|
message to the user.
|
|
|
|
```cpp
|
|
MOD_EXPORT ModResult mod_initialize(ModError* error) {
|
|
if (!load_my_data()) {
|
|
return dusk::mods::set_error(error, MOD_ERROR, "failed to load data");
|
|
}
|
|
return MOD_OK;
|
|
}
|
|
```
|
|
|
|
Throwing exceptions out of lifecycle functions also disables the mod (they are caught by the loader), but prefer
|
|
explicit results.
|
|
|
|
---
|
|
|
|
## Advanced: Exporting Services
|
|
|
|
Mods can provide services to other mods. Define the interface in a header both mods share:
|
|
|
|
```cpp
|
|
// my_mod_api.h
|
|
#include "mods/api.h"
|
|
|
|
#define MY_MOD_SERVICE_ID "com.example.my_mod.api"
|
|
#define MY_MOD_SERVICE_MAJOR 1u
|
|
#define MY_MOD_SERVICE_MINOR 0u
|
|
|
|
typedef struct MyModService {
|
|
ServiceHeader header;
|
|
ModResult (*do_thing)(ModContext* ctx, int value);
|
|
} MyModService;
|
|
|
|
#ifdef __cplusplus
|
|
#include "mods/service.hpp"
|
|
template <>
|
|
struct dusk::mods::ServiceTraits<MyModService> {
|
|
static constexpr const char* id = MY_MOD_SERVICE_ID;
|
|
static constexpr uint16_t major_version = MY_MOD_SERVICE_MAJOR;
|
|
};
|
|
#endif
|
|
```
|
|
|
|
**Provider:**
|
|
|
|
```cpp
|
|
ModResult do_thing(ModContext* ctx, int value) { ... }
|
|
|
|
constexpr MyModService g_service{
|
|
.header = SERVICE_HEADER(MyModService, MY_MOD_SERVICE_MAJOR, MY_MOD_SERVICE_MINOR),
|
|
.do_thing = do_thing,
|
|
};
|
|
EXPORT_SERVICE(g_service);
|
|
```
|
|
|
|
**Consumer:**
|
|
|
|
```cpp
|
|
IMPORT_SERVICE(MyModService, svc_my_mod);
|
|
// or IMPORT_OPTIONAL_SERVICE if the dependency is optional
|
|
|
|
svc_my_mod->do_thing(mod_ctx, 42);
|
|
```
|
|
|
|
The loader registers all exports before resolving any imports, so declaration order between mods doesn't matter. Note
|
|
that the `ctx` a provider receives identifies the *calling* mod.
|
|
|
|
### Dependencies between mods
|
|
|
|
Service imports are also dependency declarations: the loader initializes mods in dependency order, so by the time your
|
|
`mod_initialize` runs, every mod you import services from (required *or* optional) has already finished its own
|
|
`mod_initialize`. This includes deferred services: a service the provider publishes during its initialization resolves
|
|
into your import slot just like a static export.
|
|
|
|
Consequences of that contract:
|
|
|
|
- If a provider fails to load, every mod that *requires* one of its services is disabled too, with an error naming the
|
|
provider. Optional imports of a failed provider simply resolve to `NULL`.
|
|
- Mods whose **required** imports form a cycle all fail to load. If the cycle runs through an **optional** import, the
|
|
loader breaks it there: the optional import still resolves, but its provider may not be initialized yet when you run.
|
|
- `svc_host->get_service(...)` is outside this system. It sees whatever is published at call time and gives no
|
|
initialization-order guarantee, which also makes it the escape hatch for intentionally cyclic designs.
|
|
|
|
Mods shut down in reverse initialization order, so services you import remain safe to call from `mod_shutdown`.
|
|
|
|
Rules for providers:
|
|
|
|
- Service IDs are global and use reverse-DNS names (e.g. `com.mydomain.mod.service`)
|
|
- Every function pointer covered by your declared minor version must be populated.
|
|
- Within a major version, only append fields; never reorder, remove, or repurpose them. Breaking changes require a major
|
|
bump (which is, in effect, a new service).
|
|
- Only one provider per `(id, major)` pair may be registered; duplicates are load errors.
|
|
|
|
For services whose construction can't happen at static-init time, declare the export with `EXPORT_DEFERRED_SERVICE(...)`
|
|
and publish the pointer later via `svc_host->publish_service(...)`. Consumers can fetch services dynamically with
|
|
`svc_host->get_service(...)`; prefer manifest imports whenever possible, since they give the loader dependency
|
|
information and fail fast with good errors.
|