From af5635dd42c16cc14626564e5eb7f95f26cd83ec Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 7 Jul 2026 13:55:55 -0600 Subject: [PATCH] Resource, texture and overlay services --- cmake/ModSDK.cmake | 23 +- docs/modding.md | 221 ++++++++++--- extern/aurora | 2 +- files.cmake | 3 + include/dusk/mod_loader.hpp | 2 +- include/dusk/texture_replacements.hpp | 5 + include/mods/svc/overlay.h | 57 ++++ include/mods/svc/resource.h | 52 +++ include/mods/svc/texture.h | 88 +++++ src/dusk/mods/svc/overlay.cpp | 351 ++++++++++++++++++++ src/dusk/mods/svc/registry.cpp | 3 + src/dusk/mods/svc/registry.hpp | 3 + src/dusk/mods/svc/resource.cpp | 102 ++++++ src/dusk/mods/svc/texture.cpp | 455 ++++++++++++++++++++++++++ src/dusk/texture_replacements.cpp | 3 +- 15 files changed, 1322 insertions(+), 48 deletions(-) create mode 100644 include/mods/svc/overlay.h create mode 100644 include/mods/svc/resource.h create mode 100644 include/mods/svc/texture.h create mode 100644 src/dusk/mods/svc/overlay.cpp create mode 100644 src/dusk/mods/svc/resource.cpp create mode 100644 src/dusk/mods/svc/texture.cpp diff --git a/cmake/ModSDK.cmake b/cmake/ModSDK.cmake index e15e892a58..909e8abcba 100644 --- a/cmake/ModSDK.cmake +++ b/cmake/ModSDK.cmake @@ -1,4 +1,5 @@ -# add_mod( SOURCES ... MOD_JSON [RES_DIR ] [OUTPUT_DIR ] [BUNDLE]) +# add_mod( SOURCES ... MOD_JSON [RES_DIR ] [OVERLAY_DIR ] +# [TEXTURES_DIR ] [OUTPUT_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) diff --git a/docs/modding.md b/docs/modding.md index 28fc19af5f..77becddf0b 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -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//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//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 ` 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 ` 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 ` 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..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..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. diff --git a/extern/aurora b/extern/aurora index 9087a409da..0a5a5d90ef 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 9087a409da35b17446af12d7456ec6563cf2dd43 +Subproject commit 0a5a5d90efd2dbc7b402ab61f1021922dbf7496f diff --git a/files.cmake b/files.cmake index fb4b8dc25c..03d7a3af66 100644 --- a/files.cmake +++ b/files.cmake @@ -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 diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index 20b4de8e70..7f9d1ad340 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -153,7 +153,7 @@ struct LoadedMod { std::unique_ptr native; std::unique_ptr 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 bundle; ModManifestInfo manifestInfo; diff --git a/include/dusk/texture_replacements.hpp b/include/dusk/texture_replacements.hpp index ffb3fe8d62..85cb59b001 100644 --- a/include/dusk/texture_replacements.hpp +++ b/include/dusk/texture_replacements.hpp @@ -1,8 +1,13 @@ #ifndef DUSK_TEXTURE_REPLACEMENTS_HPP #define DUSK_TEXTURE_REPLACEMENTS_HPP +#include + namespace dusk::texture_replacements { +// Mod replacements are prioritized *over* user replacements (/texture_replacements/) +inline constexpr int32_t kUserTextureReplacementPriority = -1'000'000; + void reload(); void set_enabled(bool enabled); void shutdown(); diff --git a/include/mods/svc/overlay.h b/include/mods/svc/overlay.h new file mode 100644 index 0000000000..9fc86fc850 --- /dev/null +++ b/include/mods/svc/overlay.h @@ -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 { + static constexpr const char* id = OVERLAY_SERVICE_ID; + static constexpr uint16_t major_version = OVERLAY_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/resource.h b/include/mods/svc/resource.h new file mode 100644 index 0000000000..1173b2ab1d --- /dev/null +++ b/include/mods/svc/resource.h @@ -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 { + static constexpr const char* id = RESOURCE_SERVICE_ID; + static constexpr uint16_t major_version = RESOURCE_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/texture.h b/include/mods/svc/texture.h new file mode 100644 index 0000000000..d664c5f99d --- /dev/null +++ b/include/mods/svc/texture.h @@ -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 { + static constexpr const char* id = TEXTURE_SERVICE_ID; + static constexpr uint16_t major_version = TEXTURE_SERVICE_MAJOR; +}; +#endif diff --git a/src/dusk/mods/svc/overlay.cpp b/src/dusk/mods/svc/overlay.cpp new file mode 100644 index 0000000000..13cab1ac0f --- /dev/null +++ b/src/dusk/mods/svc/overlay.cpp @@ -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 +#include +#include +#include +#include +#include + +using namespace std::string_literals; + +namespace dusk::mods::svc { +namespace { + +aurora::Module Log("dusk::mods::overlay"); + +struct OverlayFileData { + std::string bundlePath; + std::shared_ptr bundle; + std::shared_ptr > 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 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> buffer; // buffer-backed otherwise + size_t size = 0; +}; +std::unordered_map> 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& 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& files, LoadedMod& mod, + std::unordered_map& 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(id), size); + } +} + +void append_runtime_overlays(std::vector& files, LoadedMod& mod, + std::unordered_map& 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(id), entry.size); + } +} + +struct OpenOverlayFile { + std::vector ownedData; + std::shared_ptr > shared; + size_t pos = 0; + + [[nodiscard]] const std::vector& data() const { + return shared != nullptr ? *shared : ownedData; + } +}; + +void* cbOpen(void* userdata) { + const auto id = reinterpret_cast(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(handle); + delete openFile; +} + +int64_t cbRead(void* handle, uint8_t* buf, const size_t len) { + auto& openFile = *static_cast(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(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(handle); + const auto posSigned = + std::clamp(offset, static_cast(0), static_cast(openFile.data().size())); + openFile.pos = static_cast(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 files; + std::unordered_map 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(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 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>(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(data); + const auto handle = overlay_add_buffer(*mod, discPath, std::vector{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 diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index fd5909d996..31f67bc7ab 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -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, }) { diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index 4954c97077..6ffa1a7582 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -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 diff --git a/src/dusk/mods/svc/resource.cpp b/src/dusk/mods/svc/resource.cpp new file mode 100644 index 0000000000..d96973f785 --- /dev/null +++ b/src/dusk/mods/svc/resource.cpp @@ -0,0 +1,102 @@ +#include "registry.hpp" + +#include "aurora/lib/logging.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/resource.h" + +#include + +#include +#include +#include +#include + +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 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 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 diff --git a/src/dusk/mods/svc/texture.cpp b/src/dusk/mods/svc/texture.cpp new file mode 100644 index 0000000000..7eada55739 --- /dev/null +++ b/src/dusk/mods/svc/texture.cpp @@ -0,0 +1,455 @@ +#include "registry.hpp" + +#include "aurora/lib/logging.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/texture.h" + +#include +#include + +#include +#include +#include +#include +#include + +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 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 bundle; + std::string bundlePath; + std::vector 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& outBytes) { + auto* keepalive = static_cast(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 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> staticKeepalives; + std::vector 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 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(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 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 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(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(); + 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(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 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(data->data); + const auto handle = texture_register_raw(*mod, *translatedKey, + { + .data = std::vector{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 diff --git a/src/dusk/texture_replacements.cpp b/src/dusk/texture_replacements.cpp index 5477a38dd9..2866af855f 100644 --- a/src/dusk/texture_replacements.cpp +++ b/src/dusk/texture_replacements.cpp @@ -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()); }