mirror of
https://github.com/TwilitRealm/dusklight
synced 2026-07-09 04:30:49 -04:00
Resource, texture and overlay services
This commit is contained in:
+21
-2
@@ -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
@@ -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.
|
||||
|
||||
Vendored
+1
-1
Submodule extern/aurora updated: 9087a409da...0a5a5d90ef
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user