17 KiB
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
- Getting Started
- mod.json
- Anatomy of a Code Mod
- Services
- Built-in Services
- Asset Overlays
- Runtime Lifecycle
- Error Handling
- Advanced: Exporting Services
Getting Started
Fork the 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_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
{
"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
#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:
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)
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.
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:
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.
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.
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). Overlay a disc path with a file from your bundle, or with a caller-owned
buffer (copied on registration):
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 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). Two forms: raw texel data with an explicit key, or an encoded .dds/.png from
your bundle whose filename encodes the key:
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 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:
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 and TextureService.
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_initializeruns again. You never see a secondmod_initializeon the same image, so just makemod_shutdownrelease anything the loader doesn't manage for you (threads, files, game-side state you mutated). - Reload additionally re-reads the
.duskfrom 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.
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:
// 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:
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:
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.