Resource, texture and overlay services

This commit is contained in:
Luke Street
2026-07-07 13:55:55 -06:00
parent c876ea558f
commit af5635dd42
15 changed files with 1322 additions and 48 deletions
+21 -2
View File
@@ -1,4 +1,5 @@
# add_mod(<target> SOURCES <file>... MOD_JSON <mod.json> [RES_DIR <res>] [OUTPUT_DIR <dir>] [BUNDLE])
# add_mod(<target> SOURCES <file>... MOD_JSON <mod.json> [RES_DIR <res>] [OVERLAY_DIR <overlay>]
# [TEXTURES_DIR <textures>] [OUTPUT_DIR <dir>] [BUNDLE])
set(DUSK_MODS_OUTPUT_DIR "${CMAKE_BINARY_DIR}/mods" CACHE PATH "Directory to write mod packages into")
function(_mod_lib_name out_var)
@@ -44,7 +45,7 @@ function(_mod_collect_assets out_var dir)
endfunction()
function(add_mod target_name)
cmake_parse_arguments(ARG "BUNDLE" "MOD_JSON;RES_DIR;OUTPUT_DIR" "SOURCES" ${ARGN})
cmake_parse_arguments(ARG "BUNDLE" "MOD_JSON;RES_DIR;OVERLAY_DIR;TEXTURES_DIR;OUTPUT_DIR" "SOURCES" ${ARGN})
if (NOT ARG_MOD_JSON)
message(FATAL_ERROR "add_mod: MOD_JSON is required")
endif ()
@@ -104,6 +105,24 @@ function(add_mod target_name)
list(APPEND _extra_cmds COMMAND ${CMAKE_COMMAND} -E copy_directory
"${_res_dir}" "${_stage}/res")
endif ()
if (ARG_OVERLAY_DIR)
_mod_resolve_source_path(_overlay_dir "${ARG_OVERLAY_DIR}")
_mod_collect_assets(_overlay_deps "${_overlay_dir}")
list(APPEND _package_deps ${_overlay_deps})
list(APPEND _package_inputs "${_overlay_dir}" ${_overlay_deps})
list(APPEND _zip_args overlay)
list(APPEND _extra_cmds COMMAND ${CMAKE_COMMAND} -E copy_directory
"${_overlay_dir}" "${_stage}/overlay")
endif ()
if (ARG_TEXTURES_DIR)
_mod_resolve_source_path(_textures_dir "${ARG_TEXTURES_DIR}")
_mod_collect_assets(_textures_deps "${_textures_dir}")
list(APPEND _package_deps ${_textures_deps})
list(APPEND _package_inputs "${_textures_dir}" ${_textures_deps})
list(APPEND _zip_args textures)
list(APPEND _extra_cmds COMMAND ${CMAKE_COMMAND} -E copy_directory
"${_textures_dir}" "${_stage}/textures")
endif ()
set(_bundle_cmds "")
if (ARG_BUNDLE AND TARGET dusklight)
+178 -43
View File
@@ -1,8 +1,10 @@
# Dusklight Mod API
Mods are distributed as `.dusk` files: zip archives containing a `mod.json` manifest and, optionally, compiled code libraries and resources.
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.
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
@@ -11,22 +13,25 @@ Everything a mod does goes through **services**: small, versioned C APIs. Duskli
3. [Anatomy of a Code Mod](#anatomy-of-a-code-mod)
4. [Services](#services)
5. [Built-in Services](#built-in-services)
6. [Runtime Lifecycle](#runtime-lifecycle)
7. [Error Handling](#error-handling)
8. [Advanced: Exporting Services](#advanced-exporting-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:
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)
── res/ (optional bundled resources)
├── overlay/ (optional game file overrides)
└── textures/ (optional texture replacements)
```
**CMakeLists.txt:**
@@ -39,19 +44,23 @@ set(DUSKLIGHT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/dusklight" CACHE PATH "Path to du
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
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:
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.
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.
---
@@ -59,19 +68,21 @@ You can also pass `--mods <dir>` on the command line, which is handy during deve
```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": "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.
`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).
`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.
---
@@ -102,13 +113,15 @@ MOD_EXPORT ModResult mod_shutdown(ModError* error) {
}
```
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.
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:
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
@@ -118,12 +131,15 @@ 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.
- **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.
- 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.
Service versions follow one rule: a **major** bump is a breaking change (treated as a different service entirely), a *
*minor** bump only appends functions.
---
@@ -140,7 +156,26 @@ 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.
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`)
@@ -156,8 +191,8 @@ svc_host->fail(mod_ctx, MOD_ERROR, "something unrecoverable happened"); // disa
`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.
**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);
@@ -176,6 +211,59 @@ 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
@@ -214,21 +302,56 @@ Writes that store the same value are silent. Values applied from `config.json` o
---
## 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:
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, 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.
- **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.
**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.
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) {
@@ -239,7 +362,8 @@ MOD_EXPORT ModResult mod_initialize(ModError* error) {
}
```
Throwing exceptions out of lifecycle functions also disables the mod (they are caught by the loader), but prefer explicit results.
Throwing exceptions out of lifecycle functions also disables the mod (they are caught by the loader), but prefer
explicit results.
---
@@ -291,17 +415,24 @@ IMPORT_SERVICE(MyModService, svc_my_mod);
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.
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.
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.
- 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`.
@@ -309,7 +440,11 @@ 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).
- 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.
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.
+1 -1
+3
View File
@@ -1554,6 +1554,9 @@ set(DUSK_FILES
src/dusk/mods/svc/config.hpp
src/dusk/mods/svc/host.cpp
src/dusk/mods/svc/log.cpp
src/dusk/mods/svc/overlay.cpp
src/dusk/mods/svc/resource.cpp
src/dusk/mods/svc/texture.cpp
src/dusk/mods/svc/registry.cpp
src/dusk/mods/svc/registry.hpp
src/dusk/discord.cpp
+1 -1
View File
@@ -153,7 +153,7 @@ struct LoadedMod {
std::unique_ptr<NativeMod> native;
std::unique_ptr<ModContext> context;
// Shared so in-flight readers keep their bundle alive across disable/reload.
// Shared with overlay file registrations so in-flight DVD reads survive disable/reload.
std::shared_ptr<ModBundle> bundle;
ModManifestInfo manifestInfo;
+5
View File
@@ -1,8 +1,13 @@
#ifndef DUSK_TEXTURE_REPLACEMENTS_HPP
#define DUSK_TEXTURE_REPLACEMENTS_HPP
#include <cstdint>
namespace dusk::texture_replacements {
// Mod replacements are prioritized *over* user replacements (<data folder>/texture_replacements/)
inline constexpr int32_t kUserTextureReplacementPriority = -1'000'000;
void reload();
void set_enabled(bool enabled);
void shutdown();
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include "mods/api.h"
#define OVERLAY_SERVICE_ID "dev.twilitrealm.dusklight.overlay"
#define OVERLAY_SERVICE_MAJOR 1u
#define OVERLAY_SERVICE_MINOR 0u
/* Handle for a runtime overlay registration. 0 is never a valid handle. */
typedef uint64_t OverlayHandle;
/*
* Runtime DVD file overlays.
*
* Registrations are owned by the calling mod and removed automatically when it is disabled,
* reloaded, or fails. Changes are applied at the next frame boundary; data the game has already
* read stays in memory until it re-reads the file (sometimes on scene reload, sometimes on
* restart).
*
* disc_path names the file to overlay: absolute with a leading '/', matched against the disc
* case-insensitively (e.g. "/res/Stage/R04_00.arc"). Paths that do not exist on the disc are added
* as new files.
*
* If multiple sources overlay the same path, the last one wins: a mod's runtime registrations beat
* its static overlay/ files, and later-loaded mods beat earlier ones.
*/
typedef struct OverlayService {
ServiceHeader header;
/*
* Overlay disc_path with a file from the calling mod's bundle (bundle-relative path, e.g.
* "res/replacement.arc"). The file's contents are read lazily on each open, so the bundle
* file must not change size while registered.
*/
ModResult (*add_file)(
ModContext* ctx, const char* disc_path, const char* bundle_path, OverlayHandle* out_handle);
/*
* Overlay disc_path with a caller-owned buffer. The data is copied; the caller may free it
* as soon as this returns.
*/
ModResult (*add_buffer)(ModContext* ctx, const char* disc_path, const void* data, size_t size,
OverlayHandle* out_handle);
/* Remove a runtime overlay previously added by the calling mod. */
ModResult (*remove)(ModContext* ctx, OverlayHandle handle);
} OverlayService;
#ifdef __cplusplus
#include "mods/service.hpp"
template <>
struct dusk::mods::ServiceTraits<OverlayService> {
static constexpr const char* id = OVERLAY_SERVICE_ID;
static constexpr uint16_t major_version = OVERLAY_SERVICE_MAJOR;
};
#endif
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include "mods/api.h"
/*
* Read-only access to the res/ tree of the calling mod's own bundle. Reload serves the new
* bundle's contents. For writable storage, use HostService::mod_dir.
*/
#define RESOURCE_SERVICE_ID "dev.twilitrealm.dusklight.resource"
#define RESOURCE_SERVICE_MAJOR 1u
#define RESOURCE_SERVICE_MINOR 0u
/*
* A loaded resource, allocated by the service. Return every successful load with free;
* buffers still live when the mod is disabled or reloaded are reclaimed with a warning.
*/
typedef struct ResourceBuffer {
uint32_t struct_size;
void* data;
size_t size;
} ResourceBuffer;
#define RESOURCE_BUFFER_INIT {sizeof(ResourceBuffer), NULL, 0u}
typedef struct ResourceService {
ServiceHeader header;
/*
* Load a file into a fresh allocation. `relative_path` is resolved against the bundle's
* res/ directory. Absolute paths and ".." are rejected. MOD_UNAVAILABLE if the file does not
* exist. An empty file loads as data == NULL with size 0. Previous contents of `out_buffer` are
* overwritten, not freed.
*/
ModResult (*load)(ModContext* ctx, const char* relative_path, ResourceBuffer* out_buffer);
/*
* Release a loaded buffer and reset it to the empty state. Safe to call on an empty or
* already-freed buffer.
*/
void (*free)(ModContext* ctx, ResourceBuffer* buffer);
} ResourceService;
#ifdef __cplusplus
#include "mods/service.hpp"
template <>
struct dusk::mods::ServiceTraits<ResourceService> {
static constexpr const char* id = RESOURCE_SERVICE_ID;
static constexpr uint16_t major_version = RESOURCE_SERVICE_MAJOR;
};
#endif
+88
View File
@@ -0,0 +1,88 @@
#pragma once
#include "mods/api.h"
#define TEXTURE_SERVICE_ID "dev.twilitrealm.dusklight.texture"
#define TEXTURE_SERVICE_MAJOR 1u
#define TEXTURE_SERVICE_MINOR 0u
/* Handle for a runtime texture replacement registration. 0 is never a valid handle. */
typedef uint64_t TextureReplacementHandle;
typedef enum TextureKeyKind {
/* Match a texture by the address of its in-memory GX texel data. */
TEXTURE_KEY_POINTER = 0,
/* Match by content: XXH64 of the base mip level (and of the referenced TLUT range for
* palette formats), as encoded in replacement filenames / texture dumps. */
TEXTURE_KEY_SOURCE = 1,
} TextureKeyKind;
/* Wildcard values for TEXTURE_KEY_SOURCE hashes ("$" in the filename convention). */
#define TEXTURE_HASH_WILDCARD UINT64_C(0xFFFFFFFFFFFFFFFF)
#define TEXTURE_TLUT_WILDCARD UINT64_C(0xFFFFFFFFFFFFFFFE)
typedef struct TextureKey {
uint32_t struct_size;
TextureKeyKind kind;
const void* pointer; /* TEXTURE_KEY_POINTER only */
uint64_t texture_hash; /* TEXTURE_KEY_SOURCE */
uint64_t tlut_hash; /* TEXTURE_KEY_SOURCE, palette formats only */
uint32_t width;
uint32_t height;
uint32_t gx_format;
bool has_tlut;
} TextureKey;
#define TEXTURE_KEY_INIT {sizeof(TextureKey), TEXTURE_KEY_POINTER, NULL, 0u, 0u, 0u, 0u, 0u, false}
typedef struct TextureData {
uint32_t struct_size;
const void* data; /* texel data laid out in gx_format; copied by the service */
size_t size;
uint32_t width;
uint32_t height;
uint32_t mip_count;
uint32_t gx_format; /* any GX texture format supported by Aurora's converter */
} TextureData;
#define TEXTURE_DATA_INIT {sizeof(TextureData), NULL, 0u, 0u, 0u, 1u, 0u}
/*
* Runtime texture replacements.
*
* Registrations are owned by the calling mod and removed automatically when it is disabled,
* reloaded, or fails. When multiple sources replace the same texture, the highest priority wins:
* later-loaded mods beat earlier ones, and any mod beats the user's texture_replacements config
* directory. Files shipped in a mod's textures/ directory register automatically with the same
* ownership and priority; this service is for replacements decided at runtime.
*/
typedef struct TextureService {
ServiceHeader header;
/* Register a replacement from raw texel data. The data is copied; the caller may free it as
* soon as this returns. */
ModResult (*register_data)(ModContext* ctx, const TextureKey* key, const TextureData* data,
TextureReplacementHandle* out_handle);
/*
* Register a replacement from an encoded .dds/.png inside the calling mod's bundle. The
* filename encodes the key (same convention as the texture_replacements directory, e.g.
* "tex1_{w}x{h}_{hash}_{fmt}.dds"); "_mipN" sidecars next to it are picked up automatically.
* The file is decoded lazily on first use by the renderer.
*/
ModResult (*register_file)(ModContext* ctx, const char* bundle_path,
TextureReplacementHandle* out_handle);
/* Remove a replacement previously registered by the calling mod. */
ModResult (*unregister)(ModContext* ctx, TextureReplacementHandle handle);
} TextureService;
#ifdef __cplusplus
#include "mods/service.hpp"
template <>
struct dusk::mods::ServiceTraits<TextureService> {
static constexpr const char* id = TEXTURE_SERVICE_ID;
static constexpr uint16_t major_version = TEXTURE_SERVICE_MAJOR;
};
#endif
+351
View File
@@ -0,0 +1,351 @@
#include "registry.hpp"
#include "aurora/dvd.h"
#include "aurora/lib/logging.hpp"
#include "dusk/mods/loader/loader.hpp"
#include "mods/svc/overlay.h"
#include <cstdint>
#include <cstring>
#include <mutex>
#include <string_view>
#include <unordered_map>
#include <utility>
using namespace std::string_literals;
namespace dusk::mods::svc {
namespace {
aurora::Module Log("dusk::mods::overlay");
struct OverlayFileData {
std::string bundlePath;
std::shared_ptr<ModBundle> bundle;
std::shared_ptr<const std::vector<u8> > buffer;
};
// Keyed by the id passed to Aurora as per-file userdata. Guarded by s_overlayMutex: Aurora may
// call cbOpen from a DVD thread while the game thread replaces the set in overlay_sync_files.
// The shared bundle/buffer pointer keeps a disabled/reloaded mod's data readable until the last
// open completes.
std::unordered_map<uintptr_t, OverlayFileData> s_overlayFiles;
uintptr_t s_nextOverlayId = 1;
std::mutex s_overlayMutex;
struct RuntimeOverlayEntry {
uint64_t handle = 0;
std::string discPath;
std::string bundlePath; // bundle-backed if non-empty
std::shared_ptr<const std::vector<u8>> buffer; // buffer-backed otherwise
size_t size = 0;
};
std::unordered_map<const LoadedMod*, std::vector<RuntimeOverlayEntry>> s_runtimeOverlays;
uint64_t s_nextRuntimeHandle = 1;
bool s_overlaysDirty = false;
// Aurora matches overlay paths against the disc case-insensitively and later entries win, so
// claims are tracked by lowercased path and re-claims by a different mod warn.
void claim_overlay_path(std::unordered_map<std::string, const LoadedMod*>& claims,
const std::string& discPath, const LoadedMod& mod) {
std::string key = discPath;
for (auto& ch : key) {
if (ch >= 'A' && ch <= 'Z') {
ch += 'a' - 'A';
}
}
const auto [it, inserted] = claims.try_emplace(std::move(key), &mod);
if (!inserted && it->second != &mod) {
Log.warn("Overlay conflict: '{}' is provided by both '{}' and '{}'; '{}' wins.", discPath,
it->second->metadata.id, mod.metadata.id, mod.metadata.id);
it->second = &mod;
}
}
void find_overlay_files(std::vector<AuroraOverlayFile>& files, LoadedMod& mod,
std::unordered_map<std::string, const LoadedMod*>& claims) {
for (const auto& file : mod.bundle->getFileNames()) {
if (!file.starts_with("overlay/")) {
continue;
}
auto overlayPath = file.substr("overlay/"s.size());
assert(!overlayPath.starts_with('/'));
overlayPath.insert(0, "/");
const auto size = mod.bundle->getFileSize(file);
const auto id = s_nextOverlayId++;
s_overlayFiles.emplace(id, OverlayFileData{file, mod.bundle, nullptr});
claim_overlay_path(claims, overlayPath, mod);
files.emplace_back(strdup(overlayPath.c_str()), reinterpret_cast<void*>(id), size);
}
}
void append_runtime_overlays(std::vector<AuroraOverlayFile>& files, LoadedMod& mod,
std::unordered_map<std::string, const LoadedMod*>& claims) {
const auto it = s_runtimeOverlays.find(&mod);
if (it == s_runtimeOverlays.end()) {
return;
}
for (const auto& entry : it->second) {
const auto id = s_nextOverlayId++;
if (entry.buffer != nullptr) {
s_overlayFiles.emplace(id, OverlayFileData{{}, nullptr, entry.buffer});
} else {
s_overlayFiles.emplace(id, OverlayFileData{entry.bundlePath, mod.bundle, nullptr});
}
claim_overlay_path(claims, entry.discPath, mod);
files.emplace_back(strdup(entry.discPath.c_str()), reinterpret_cast<void*>(id), entry.size);
}
}
struct OpenOverlayFile {
std::vector<u8> ownedData;
std::shared_ptr<const std::vector<u8> > shared;
size_t pos = 0;
[[nodiscard]] const std::vector<u8>& data() const {
return shared != nullptr ? *shared : ownedData;
}
};
void* cbOpen(void* userdata) {
const auto id = reinterpret_cast<uintptr_t>(userdata);
OverlayFileData fileData;
{
std::lock_guard lock{s_overlayMutex};
const auto it = s_overlayFiles.find(id);
if (it == s_overlayFiles.end()) {
// The overlay set was re-pushed between the FST lookup and this call.
return nullptr;
}
fileData = it->second;
}
if (fileData.buffer != nullptr) {
return new OpenOverlayFile{.shared = std::move(fileData.buffer)};
}
try {
auto fileContents = fileData.bundle->readFile(fileData.bundlePath);
return new OpenOverlayFile{.ownedData = std::move(fileContents)};
} catch (const std::runtime_error& e) {
Log.error("Failed to read overlay file {}: {}", fileData.bundlePath, e.what());
return nullptr;
}
}
void cbClose(void* handle) {
const auto openFile = static_cast<OpenOverlayFile*>(handle);
delete openFile;
}
int64_t cbRead(void* handle, uint8_t* buf, const size_t len) {
auto& openFile = *static_cast<OpenOverlayFile*>(handle);
const auto remainingSpace = openFile.data().size() - openFile.pos;
const auto toRead = std::min(remainingSpace, len);
std::memcpy(buf, openFile.data().data() + openFile.pos, toRead);
openFile.pos += toRead;
return static_cast<int64_t>(toRead);
}
int64_t cbSeek(void* handle, int64_t offset, int32_t whence) {
if (whence != 0) {
Log.fatal("Invalid seek mode from aurora: {}", whence);
}
auto& openFile = *static_cast<OpenOverlayFile*>(handle);
const auto posSigned =
std::clamp(offset, static_cast<int64_t>(0), static_cast<int64_t>(openFile.data().size()));
openFile.pos = static_cast<size_t>(posSigned);
return posSigned;
}
constexpr AuroraOverlayCallbacks s_overlayCallbacks = {
.open = cbOpen,
.close = cbClose,
.read = cbRead,
.seek = cbSeek,
};
void overlay_sync_files() {
static bool callbacksRegistered = false;
if (!callbacksRegistered) {
aurora_dvd_overlay_callbacks(&s_overlayCallbacks);
callbacksRegistered = true;
}
s_overlaysDirty = false;
std::vector<AuroraOverlayFile> files;
std::unordered_map<std::string, const LoadedMod*> claims;
{
std::lock_guard lock{s_overlayMutex};
s_overlayFiles.clear();
for (auto& mod : ModLoader::instance().active_mods()) {
find_overlay_files(files, mod, claims);
append_runtime_overlays(files, mod, claims);
}
}
Log.debug("Registering {} overlay file(s).", files.size());
aurora_dvd_overlay_files(files.data(), files.size(), nullptr);
for (const auto& file : files) {
std::free(const_cast<char*>(file.fileName));
}
}
uint64_t overlay_add_file(
LoadedMod& mod, std::string discPath, std::string bundlePath, size_t size) {
const auto handle = s_nextRuntimeHandle++;
s_runtimeOverlays[&mod].push_back({
.handle = handle,
.discPath = std::move(discPath),
.bundlePath = std::move(bundlePath),
.size = size,
});
s_overlaysDirty = true;
return handle;
}
uint64_t overlay_add_buffer(LoadedMod& mod, std::string discPath, std::vector<u8> data) {
const auto handle = s_nextRuntimeHandle++;
const auto size = data.size();
s_runtimeOverlays[&mod].push_back({
.handle = handle,
.discPath = std::move(discPath),
.buffer = std::make_shared<const std::vector<u8>>(std::move(data)),
.size = size,
});
s_overlaysDirty = true;
return handle;
}
bool overlay_remove(LoadedMod& mod, uint64_t handle) {
const auto it = s_runtimeOverlays.find(&mod);
if (it == s_runtimeOverlays.end()) {
return false;
}
if (std::erase_if(it->second, [&](const auto& entry) { return entry.handle == handle; }) == 0) {
return false;
}
if (it->second.empty()) {
s_runtimeOverlays.erase(it);
}
s_overlaysDirty = true;
return true;
}
void overlay_remove_mod(LoadedMod& mod) {
if (s_runtimeOverlays.erase(&mod) != 0) {
s_overlaysDirty = true;
}
}
bool consume_overlays_dirty() {
return std::exchange(s_overlaysDirty, false);
}
constexpr size_t kMaxOverlayFileSize = UINT32_MAX;
bool is_valid_disc_path(const char* discPath) {
if (discPath == nullptr) {
return false;
}
const std::string_view path{discPath};
return path.starts_with('/') && is_safe_resource_path(path.substr(1));
}
ModResult overlay_add_file(
ModContext* context, const char* discPath, const char* bundlePath, OverlayHandle* outHandle) {
if (outHandle != nullptr) {
*outHandle = 0;
}
auto* mod = mod_from_context(context);
if (mod == nullptr || !is_valid_disc_path(discPath) || bundlePath == nullptr ||
!is_safe_resource_path(bundlePath))
{
return MOD_INVALID_ARGUMENT;
}
size_t size = 0;
try {
size = mod->bundle->getFileSize(bundlePath);
} catch (const std::exception& e) {
Log.error(
"[{}] overlay add_file '{}' failed: {}", mod->metadata.id, bundlePath, e.what());
return MOD_UNAVAILABLE;
}
if (size > kMaxOverlayFileSize) {
Log.error("[{}] overlay add_file '{}' failed: file too large ({} bytes)",
mod->metadata.id, bundlePath, size);
return MOD_INVALID_ARGUMENT;
}
const auto handle = overlay_add_file(*mod, discPath, bundlePath, size);
if (outHandle != nullptr) {
*outHandle = handle;
}
return MOD_OK;
}
ModResult overlay_add_buffer(ModContext* context, const char* discPath, const void* data,
size_t size, OverlayHandle* outHandle) {
if (outHandle != nullptr) {
*outHandle = 0;
}
auto* mod = mod_from_context(context);
if (mod == nullptr || !is_valid_disc_path(discPath) || (data == nullptr && size != 0) ||
size > kMaxOverlayFileSize)
{
return MOD_INVALID_ARGUMENT;
}
const auto* bytes = static_cast<const u8*>(data);
const auto handle = overlay_add_buffer(*mod, discPath, std::vector<u8>{bytes, bytes + size});
if (outHandle != nullptr) {
*outHandle = handle;
}
return MOD_OK;
}
ModResult overlay_remove(ModContext* context, OverlayHandle handle) {
auto* mod = mod_from_context(context);
if (mod == nullptr || handle == 0) {
return MOD_INVALID_ARGUMENT;
}
if (!overlay_remove(*mod, handle)) {
Log.error("[{}] overlay remove failed: unknown handle {}", mod->metadata.id, handle);
return MOD_INVALID_ARGUMENT;
}
return MOD_OK;
}
constexpr OverlayService s_overlayService{
.header = SERVICE_HEADER(OverlayService, OVERLAY_SERVICE_MAJOR, OVERLAY_SERVICE_MINOR),
.add_file = overlay_add_file,
.add_buffer = overlay_add_buffer,
.remove = overlay_remove,
};
} // namespace
constinit const ServiceModule g_overlayModule{
.id = OVERLAY_SERVICE_ID,
.majorVersion = OVERLAY_SERVICE_MAJOR,
.minorVersion = OVERLAY_SERVICE_MINOR,
.service = &s_overlayService,
.modDetached = overlay_remove_mod,
.lifecycleApplied = overlay_sync_files,
.frameEnd =
[] {
if (consume_overlays_dirty()) {
overlay_sync_files();
}
},
};
} // namespace dusk::mods::svc
+3
View File
@@ -199,6 +199,9 @@ void ModLoader::init_services() {
{
&svc::g_hostModule,
&svc::g_logModule,
&svc::g_resourceModule,
&svc::g_overlayModule,
&svc::g_textureModule,
&svc::g_configModule,
})
{
+3
View File
@@ -64,6 +64,9 @@ void modules_shutdown();
extern const ServiceModule g_hostModule;
extern const ServiceModule g_logModule;
extern const ServiceModule g_resourceModule;
extern const ServiceModule g_overlayModule;
extern const ServiceModule g_textureModule;
extern const ServiceModule g_configModule;
} // namespace dusk::mods::svc
+102
View File
@@ -0,0 +1,102 @@
#include "registry.hpp"
#include "aurora/lib/logging.hpp"
#include "dusk/mods/loader/loader.hpp"
#include "mods/svc/resource.h"
#include <fmt/format.h>
#include <cstdlib>
#include <cstring>
#include <string>
#include <unordered_map>
namespace dusk::mods::svc {
namespace {
aurora::Module Log("dusk::mods::resource");
// Allocations by owning mod, so buffers still live when a mod detaches can be freed.
std::unordered_map<void*, const LoadedMod*> s_buffers;
void resource_remove_mod(LoadedMod& mod) {
size_t reclaimed = 0;
std::erase_if(s_buffers, [&](const auto& entry) {
if (entry.second != &mod) {
return false;
}
std::free(entry.first);
++reclaimed;
return true;
});
if (reclaimed != 0) {
Log.warn("[{}] reclaimed {} resource buffer(s) that were never freed", mod.metadata.id,
reclaimed);
}
}
ModResult resource_load(ModContext* context, const char* relativePath, ResourceBuffer* outBuffer) {
if (outBuffer == nullptr || outBuffer->struct_size < sizeof(ResourceBuffer)) {
return MOD_INVALID_ARGUMENT;
}
outBuffer->data = nullptr;
outBuffer->size = 0;
auto* mod = mod_from_context(context);
if (mod == nullptr || relativePath == nullptr || !is_safe_resource_path(relativePath)) {
return MOD_INVALID_ARGUMENT;
}
const auto entry = fmt::format("res/{}", relativePath);
std::vector<u8> data;
try {
data = mod->bundle->readFile(entry);
} catch (const std::runtime_error& e) {
Log.error("[{}] resource load '{}' failed: {}", mod->metadata.id, entry, e.what());
return MOD_UNAVAILABLE;
}
if (!data.empty()) {
void* copy = std::malloc(data.size());
if (copy == nullptr) {
return MOD_ERROR;
}
std::memcpy(copy, data.data(), data.size());
s_buffers.emplace(copy, mod);
outBuffer->data = copy;
outBuffer->size = data.size();
}
return MOD_OK;
}
void resource_free(ModContext* context, ResourceBuffer* buffer) {
if (buffer == nullptr || buffer->struct_size < sizeof(ResourceBuffer) ||
buffer->data == nullptr)
{
return;
}
if (s_buffers.erase(buffer->data) == 0) {
Log.error("[{}] resource free: not a live loaded buffer", mod_id_from_context(context));
return;
}
std::free(buffer->data);
buffer->data = nullptr;
buffer->size = 0;
}
constexpr ResourceService s_resourceService{
.header = SERVICE_HEADER(ResourceService, RESOURCE_SERVICE_MAJOR, RESOURCE_SERVICE_MINOR),
.load = resource_load,
.free = resource_free,
};
} // namespace
constinit const ServiceModule g_resourceModule{
.id = RESOURCE_SERVICE_ID,
.majorVersion = RESOURCE_SERVICE_MAJOR,
.minorVersion = RESOURCE_SERVICE_MINOR,
.service = &s_resourceService,
.modDetached = resource_remove_mod,
};
} // namespace dusk::mods::svc
+455
View File
@@ -0,0 +1,455 @@
#include "registry.hpp"
#include "aurora/lib/logging.hpp"
#include "dusk/mods/loader/loader.hpp"
#include "mods/svc/texture.h"
#include <aurora/texture.hpp>
#include <fmt/format.h>
#include <algorithm>
#include <memory>
#include <optional>
#include <string_view>
#include <unordered_map>
using namespace std::string_literals;
static_assert(TEXTURE_HASH_WILDCARD == aurora::texture::kWildcardTextureHash);
static_assert(TEXTURE_TLUT_WILDCARD == aurora::texture::kWildcardTlutHash);
namespace dusk::mods::svc {
namespace {
struct TextureRawData {
std::vector<u8> data;
uint32_t width = 0;
uint32_t height = 0;
uint32_t mipCount = 1;
uint32_t gxFormat = 0;
};
aurora::Module Log("dusk::mods::textures");
// Referenced by Aurora's lazy virtual-file reads (from arbitrary threads, under Aurora's registry
// lock) and by raw-entry spans. Immutable after construction; freed only after the corresponding
// unregister_replacement returns, at which point Aurora guarantees no further reads.
struct TextureKeepalive {
std::shared_ptr<ModBundle> bundle;
std::string bundlePath;
std::vector<u8> ownedData;
};
// Called with Aurora's registry lock held: must not take any Dusk lock or re-enter
// aurora::texture. ModBundle reads are documented thread-safe.
bool texture_read_cb(void* userData, const char* path, std::vector<uint8_t>& outBytes) {
auto* keepalive = static_cast<TextureKeepalive*>(userData);
try {
outBytes = keepalive->bundle->readFile(path);
return true;
} catch (...) {
return false;
}
}
struct RuntimeTextureEntry {
uint64_t handle = 0;
aurora::texture::ReplacementRegistration registration;
std::shared_ptr<TextureKeepalive> keepalive;
// Original inputs, kept for re-registration when the mod's priority changes.
bool isVirtual = false;
aurora::texture::ReplacementKey key; // raw entries only
uint32_t width = 0;
uint32_t height = 0;
uint32_t mipCount = 1;
uint32_t gxFormat = 0;
std::string label;
};
struct ModTextureRecord {
int32_t appliedPriority = 0;
bool staticRegistered = false;
aurora::texture::ReplacementGroup staticGroup;
std::vector<std::shared_ptr<TextureKeepalive>> staticKeepalives;
std::vector<RuntimeTextureEntry> runtime;
};
// Game thread only: all mutations happen in service calls made from mod code (init/update/hooks
// run inside ModLoader::tick), in the loader's sync/deactivate paths, or at shutdown.
std::unordered_map<const LoadedMod*, ModTextureRecord> s_modTextures;
uint64_t s_nextTextureHandle = 1;
// Position in m_mods (dependency-sorted load order) + 1; later-loaded mods win. The user
// texture_replacements directory uses kUserTextureReplacementPriority, below any mod.
int32_t compute_mod_priority(const LoadedMod& mod) {
int32_t index = 0;
for (const auto& other : ModLoader::instance().mods()) {
++index;
if (&other == &mod) {
return index;
}
}
return index + 1;
}
bool is_sidecar_mip(std::string_view stem) {
constexpr std::string_view tag = "_mip";
size_t i = stem.size();
while (i > 0 && stem[i - 1] >= '0' && stem[i - 1] <= '9') {
--i;
}
if (i == stem.size() || i < tag.size()) {
return false;
}
return stem.substr(i - tag.size(), tag.size()) == tag;
}
bool has_replacement_extension(std::string_view filename) {
const auto dot = filename.rfind('.');
if (dot == std::string_view::npos) {
return false;
}
std::string ext{filename.substr(dot)};
std::ranges::transform(ext, ext.begin(),
[](char ch) { return ch >= 'A' && ch <= 'Z' ? static_cast<char>(ch + 'a' - 'A') : ch; });
return ext == ".dds" || ext == ".png";
}
std::string_view final_path_component(std::string_view path) {
const auto slash = path.rfind('/');
return slash == std::string_view::npos ? path : path.substr(slash + 1);
}
const LoadedMod* find_static_conflict(
const aurora::texture::ReplacementKey& key, const LoadedMod* exclude) {
for (const auto& [mod, record] : s_modTextures) {
if (mod == exclude) {
continue;
}
for (const auto& registration : record.staticGroup.registrations) {
if (registration.key == key) {
return mod;
}
}
}
return nullptr;
}
void register_static_textures(LoadedMod& mod, ModTextureRecord& record) {
std::vector<std::string> candidates;
for (const auto& file : mod.bundle->getFileNames()) {
if (!file.starts_with("textures/") || !has_replacement_extension(file)) {
continue;
}
auto filename = final_path_component(file);
if (is_sidecar_mip(filename.substr(0, filename.rfind('.')))) {
continue;
}
candidates.push_back(file);
}
// Deterministic order; with the first parse of a key winning, this mirrors Aurora's
// load_replacement_directory dedupe semantics.
std::ranges::sort(candidates);
std::vector<aurora::texture::ReplacementKey> seenKeys;
for (const auto& path : candidates) {
const auto parsed = aurora::texture::parse_replacement_filename(final_path_component(path));
if (!parsed.has_value()) {
Log.warn(
"[{}] '{}' does not follow the texture replacement naming convention; skipped.",
mod.metadata.id, path);
continue;
}
const aurora::texture::ReplacementKey key{*parsed};
if (std::ranges::find(seenKeys, key) != seenKeys.end()) {
continue;
}
seenKeys.push_back(key);
if (const auto* other = find_static_conflict(key, &mod); other != nullptr) {
const auto& winner =
s_modTextures.find(other)->second.appliedPriority > record.appliedPriority ?
*other :
mod;
Log.warn(
"Texture replacement conflict: '{}' is replaced by both '{}' and '{}'; '{}' wins.",
path, other->metadata.id, mod.metadata.id, winner.metadata.id);
}
auto keepalive = std::make_shared<TextureKeepalive>(mod.bundle, path);
const auto registration = aurora::texture::register_virtual_replacement(path,
{.read = texture_read_cb, .userData = keepalive.get()},
{.priority = record.appliedPriority});
if (registration.id == 0) {
continue;
}
record.staticGroup.registrations.push_back(registration);
record.staticKeepalives.push_back(std::move(keepalive));
}
record.staticRegistered = true;
if (!record.staticGroup.registrations.empty()) {
Log.info("[{}] registered {} texture replacement(s).", mod.metadata.id,
record.staticGroup.registrations.size());
}
}
void register_runtime_entry(RuntimeTextureEntry& entry, int32_t priority) {
if (entry.isVirtual) {
entry.registration = aurora::texture::register_virtual_replacement(
entry.keepalive->bundlePath,
{.read = texture_read_cb, .userData = entry.keepalive.get()}, {.priority = priority});
} else {
entry.registration = aurora::texture::register_replacement(entry.key,
{
.bytes = {entry.keepalive->ownedData.data(), entry.keepalive->ownedData.size()},
.width = entry.width,
.height = entry.height,
.mipCount = entry.mipCount,
.gxFormat = entry.gxFormat,
.label = entry.label,
},
{.priority = priority});
}
}
void unregister_record(ModTextureRecord& record) {
aurora::texture::unregister_replacements(record.staticGroup);
record.staticGroup.registrations.clear();
record.staticKeepalives.clear();
record.staticRegistered = false;
for (auto& entry : record.runtime) {
aurora::texture::unregister_replacement(entry.registration);
entry.registration = {};
}
}
void textures_sync_replacements() {
// Module detach removes records eagerly, but a record whose mod is no
// longer active must not linger with stale priority.
std::erase_if(s_modTextures, [&](auto& item) {
if (item.first->active) {
return false;
}
unregister_record(item.second);
return true;
});
for (auto& mod : ModLoader::instance().active_mods()) {
const auto priority = compute_mod_priority(mod);
auto& record = s_modTextures[&mod];
if (record.staticRegistered && record.appliedPriority == priority) {
continue;
}
if (record.staticRegistered) {
// A reload re-sorted m_mods and changed this mod's priority: re-register everything
// at the new priority. Cheap, since file-backed entries decode lazily.
aurora::texture::unregister_replacements(record.staticGroup);
record.staticGroup.registrations.clear();
record.staticKeepalives.clear();
record.appliedPriority = priority;
register_static_textures(mod, record);
for (auto& entry : record.runtime) {
aurora::texture::unregister_replacement(entry.registration);
register_runtime_entry(entry, priority);
}
} else {
record.appliedPriority = priority;
register_static_textures(mod, record);
}
}
}
uint64_t texture_register_raw(
LoadedMod& mod, const aurora::texture::ReplacementKey& key, TextureRawData data) {
auto& record = s_modTextures[&mod];
if (record.appliedPriority == 0) {
record.appliedPriority = compute_mod_priority(mod);
}
auto& entry = record.runtime.emplace_back();
entry.handle = s_nextTextureHandle++;
entry.keepalive = std::make_shared<TextureKeepalive>();
entry.keepalive->ownedData = std::move(data.data);
entry.isVirtual = false;
entry.key = key;
entry.width = data.width;
entry.height = data.height;
entry.mipCount = data.mipCount;
entry.gxFormat = data.gxFormat;
entry.label = fmt::format("mod {} texture {}", mod.metadata.id, entry.handle);
register_runtime_entry(entry, record.appliedPriority);
return entry.handle;
}
uint64_t texture_register_file(LoadedMod& mod, std::string bundlePath) {
auto& record = s_modTextures[&mod];
if (record.appliedPriority == 0) {
record.appliedPriority = compute_mod_priority(mod);
}
auto& entry = record.runtime.emplace_back();
entry.handle = s_nextTextureHandle++;
entry.keepalive = std::make_shared<TextureKeepalive>(mod.bundle, std::move(bundlePath));
entry.isVirtual = true;
register_runtime_entry(entry, record.appliedPriority);
if (entry.registration.id == 0) {
record.runtime.pop_back();
return 0;
}
return entry.handle;
}
bool texture_unregister(LoadedMod& mod, uint64_t handle) {
const auto it = s_modTextures.find(&mod);
if (it == s_modTextures.end()) {
return false;
}
auto& runtime = it->second.runtime;
const auto entry =
std::ranges::find_if(runtime, [&](const auto& e) { return e.handle == handle; });
if (entry == runtime.end()) {
return false;
}
aurora::texture::unregister_replacement(entry->registration);
runtime.erase(entry);
return true;
}
void textures_remove_mod(LoadedMod& mod) {
const auto it = s_modTextures.find(&mod);
if (it == s_modTextures.end()) {
return;
}
unregister_record(it->second);
s_modTextures.erase(it);
}
std::optional<aurora::texture::ReplacementKey> translate_key(const TextureKey* key) {
if (key == nullptr || key->struct_size < sizeof(TextureKey)) {
return std::nullopt;
}
switch (key->kind) {
case TEXTURE_KEY_POINTER:
if (key->pointer == nullptr) {
return std::nullopt;
}
return aurora::texture::ReplacementKey{aurora::texture::TexturePointerKey{key->pointer}};
case TEXTURE_KEY_SOURCE:
if (key->width == 0 || key->height == 0) {
return std::nullopt;
}
return aurora::texture::ReplacementKey{aurora::texture::TextureSourceKey{
.textureHash = key->texture_hash,
.tlutHash = key->tlut_hash,
.width = key->width,
.height = key->height,
.format = key->gx_format,
.hasTlut = key->has_tlut,
}};
default:
return std::nullopt;
}
}
ModResult texture_register_data(ModContext* context, const TextureKey* key, const TextureData* data,
TextureReplacementHandle* outHandle) {
if (outHandle != nullptr) {
*outHandle = 0;
}
auto* mod = mod_from_context(context);
const auto translatedKey = translate_key(key);
if (mod == nullptr || !translatedKey.has_value() || data == nullptr ||
data->struct_size < sizeof(TextureData) || data->data == nullptr || data->size == 0 ||
data->width == 0 || data->height == 0 || data->mip_count == 0)
{
return MOD_INVALID_ARGUMENT;
}
const auto* bytes = static_cast<const u8*>(data->data);
const auto handle = texture_register_raw(*mod, *translatedKey,
{
.data = std::vector<u8>{bytes, bytes + data->size},
.width = data->width,
.height = data->height,
.mipCount = data->mip_count,
.gxFormat = data->gx_format,
});
if (outHandle != nullptr) {
*outHandle = handle;
}
return MOD_OK;
}
ModResult texture_register_file(
ModContext* context, const char* bundlePath, TextureReplacementHandle* outHandle) {
if (outHandle != nullptr) {
*outHandle = 0;
}
auto* mod = mod_from_context(context);
if (mod == nullptr || bundlePath == nullptr || !is_safe_resource_path(bundlePath)) {
return MOD_INVALID_ARGUMENT;
}
const std::string_view path{bundlePath};
const auto slash = path.rfind('/');
const auto filename = slash == std::string_view::npos ? path : path.substr(slash + 1);
if (!aurora::texture::parse_replacement_filename(filename).has_value()) {
Log.error("[{}] texture register_file '{}' failed: "
"filename does not follow the replacement naming convention",
mod->metadata.id, bundlePath);
return MOD_INVALID_ARGUMENT;
}
try {
mod->bundle->getFileSize(bundlePath);
} catch (const std::exception& e) {
Log.error(
"[{}] texture register_file '{}' failed: {}", mod->metadata.id, bundlePath, e.what());
return MOD_UNAVAILABLE;
}
const auto handle = texture_register_file(*mod, bundlePath);
if (handle == 0) {
return MOD_INVALID_ARGUMENT;
}
if (outHandle != nullptr) {
*outHandle = handle;
}
return MOD_OK;
}
ModResult texture_unregister(ModContext* context, TextureReplacementHandle handle) {
auto* mod = mod_from_context(context);
if (mod == nullptr || handle == 0) {
return MOD_INVALID_ARGUMENT;
}
if (!texture_unregister(*mod, handle)) {
Log.error(
"[{}] texture unregister failed: unknown handle {}", mod->metadata.id, handle);
return MOD_INVALID_ARGUMENT;
}
return MOD_OK;
}
constexpr TextureService s_textureService{
.header = SERVICE_HEADER(TextureService, TEXTURE_SERVICE_MAJOR, TEXTURE_SERVICE_MINOR),
.register_data = texture_register_data,
.register_file = texture_register_file,
.unregister = texture_unregister,
};
} // namespace
constinit const ServiceModule g_textureModule{
.id = TEXTURE_SERVICE_ID,
.majorVersion = TEXTURE_SERVICE_MAJOR,
.minorVersion = TEXTURE_SERVICE_MINOR,
.service = &s_textureService,
.modDetached = textures_remove_mod,
.lifecycleApplied = textures_sync_replacements,
};
} // namespace dusk::mods::svc
+2 -1
View File
@@ -20,7 +20,8 @@ void reload() {
}
const auto root = ConfigPath / "texture_replacements";
s_directoryGroup = aurora::texture::load_replacement_directory(root);
s_directoryGroup = aurora::texture::load_replacement_directory(
root, {.priority = kUserTextureReplacementPriority});
DuskLog.info("Texture replacement directory loaded: {} registration(s)",
s_directoryGroup.registrations.size());
}