diff --git a/CMakeLists.txt b/CMakeLists.txt index 6227f27f96..ec18d422bc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -223,6 +223,14 @@ if (DUSK_HAS_FUNCHOOK) endif () FetchContent_MakeAvailable(${_fetch_content_deps}) +if (DUSK_HAS_FUNCHOOK AND APPLE) + target_sources(funchook-static PRIVATE src/dusk/mods/loader/code_patch_macos.cpp) + target_include_directories(funchook-static PRIVATE src/dusk/mods/loader) + set_source_files_properties(src/dusk/mods/loader/code_patch_macos.cpp + TARGET_DIRECTORY funchook-static PROPERTIES + COMPILE_OPTIONS "-O2;-fno-sanitize=all;-fno-stack-protector") +endif () + # Use signed char on ARM to match the original game (and x86) string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" _arch) if(_arch MATCHES "^(arm|aarch64)" AND CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "GNU") @@ -249,7 +257,7 @@ find_package(Threads REQUIRED) set(GAME_COMPILE_DEFS DUSK_BUILDING_GAME=1) set(GAME_LIBS aurora::core aurora::gx aurora::gd aurora::si aurora::vi aurora::pad aurora::mtx aurora::os aurora::dvd aurora::thp aurora::card borealis::http borealis::cli borealis::crash borealis::data borealis::disc borealis::discord borealis::file_select borealis::io borealis::log borealis::net borealis::presentation borealis::sentry borealis::update borealis::ws freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt - Threads::Threads zstd::libzstd dusklight_game_headers picosha2) + Threads::Threads zstd::libzstd dusklight_game_headers picosha2 PNG::PNG) if (DUSK_HAS_FUNCHOOK) list(APPEND GAME_LIBS funchook-static) endif () diff --git a/cmake/PatchFunchook.cmake b/cmake/PatchFunchook.cmake index 6d5a6e36b4..cbd91fc38e 100644 --- a/cmake/PatchFunchook.cmake +++ b/cmake/PatchFunchook.cmake @@ -2,59 +2,55 @@ file(READ "${SOURCE_DIR}/cmake/capstone.cmake.in" _content) # Insert PATCH_COMMAND before CONFIGURE_COMMAND in the ExternalProject_Add. # Bracket args prevent cmake from substituting ${...} while writing this file. -string(REPLACE - " CONFIGURE_COMMAND \"\"" - [=[ PATCH_COMMAND "${CMAKE_COMMAND}" -DDIR=${CMAKE_CURRENT_BINARY_DIR}/capstone-src -P "${CAPSTONE_FIX_SCRIPT}" +if (NOT _content MATCHES "CAPSTONE_FIX_SCRIPT") + string(REPLACE + " CONFIGURE_COMMAND \"\"" + [=[ PATCH_COMMAND "${CMAKE_COMMAND}" -DDIR=${CMAKE_CURRENT_BINARY_DIR}/capstone-src -P "${CAPSTONE_FIX_SCRIPT}" CONFIGURE_COMMAND ""]=] - _content "${_content}") - -file(WRITE "${SOURCE_DIR}/cmake/capstone.cmake.in" "${_content}") - -file(READ "${SOURCE_DIR}/src/funchook_unix.c" _unix_content) - -# macOS rejects the POSIX mprotect RWX/RW transition for executable image pages on arm64. -# Use Mach VM_PROT_COPY for the short patch window, then restore RX permissions. -if (NOT _unix_content MATCHES "VM_PROT_READ \\| VM_PROT_WRITE \\| VM_PROT_COPY") - string(REPLACE - [=[ rv = mprotect(mstate->addr, mstate->size, prot);]=] - [=[#ifdef __APPLE__ - kern_return_t kr = vm_protect(mach_task_self(), (vm_address_t)mstate->addr, - (vm_size_t)mstate->size, FALSE, - VM_PROT_READ | VM_PROT_WRITE | VM_PROT_COPY); - if (kr == KERN_SUCCESS) { - funchook_log(funchook, " unprotect memory %p (size=%"PRIuPTR", prot=read,write,copy) <- %p (size=%"PRIuPTR")\n", - mstate->addr, mstate->size, start, len); - return 0; - } - funchook_set_error_message(funchook, "Failed to unprotect memory %p (size=%"PRIuPTR", prot=read,write,copy) <- %p (size=%"PRIuPTR", error=%s)", - mstate->addr, mstate->size, start, len, - mach_error_string(kr)); - return FUNCHOOK_ERROR_MEMORY_FUNCTION; -#endif - rv = mprotect(mstate->addr, mstate->size, prot);]=] - _unix_content "${_unix_content}") - - string(REPLACE - [=[ char errbuf[128]; - int rv = mprotect(mstate->addr, mstate->size, PROT_READ | PROT_EXEC);]=] - [=[ char errbuf[128]; -#ifdef __APPLE__ - kern_return_t kr = vm_protect(mach_task_self(), (vm_address_t)mstate->addr, - (vm_size_t)mstate->size, FALSE, - VM_PROT_READ | VM_PROT_EXECUTE); - - if (kr == KERN_SUCCESS) { - funchook_log(funchook, " protect memory %p (size=%"PRIuPTR", prot=read,exec)\n", - mstate->addr, mstate->size); - return 0; - } - funchook_set_error_message(funchook, "Failed to protect memory %p (size=%"PRIuPTR", prot=read,exec, error=%s)", - mstate->addr, mstate->size, - mach_error_string(kr)); - return FUNCHOOK_ERROR_MEMORY_FUNCTION; -#endif - int rv = mprotect(mstate->addr, mstate->size, PROT_READ | PROT_EXEC);]=] - _unix_content "${_unix_content}") + _content "${_content}") + file(WRITE "${SOURCE_DIR}/cmake/capstone.cmake.in" "${_content}") endif () -file(WRITE "${SOURCE_DIR}/src/funchook_unix.c" "${_unix_content}") +file(READ "${SOURCE_DIR}/src/funchook.c" _content) +if (NOT _content MATCHES "commit_code_patch") + string(REPLACE "#include \"funchook_internal.h\"" + "#include \"funchook_internal.h\"\n#ifdef __APPLE__\n#include \"code_patch_macos.hpp\"\n#endif" + _content "${_content}") + + foreach(_operation install uninstall) + if (_operation STREQUAL "install") + set(_expected old_code) + set(_replacement new_code) + else () + set(_expected new_code) + set(_replacement old_code) + endif () + set(_original " mem_state_t mstate; + int rv = funchook_unprotect_begin(funchook, &mstate, entry->target_func, JUMP32_BYTE_SIZE); + + if (rv != 0) { + return rv; + } + memcpy(entry->target_func, entry->${_replacement}, JUMP32_BYTE_SIZE); + rv = funchook_unprotect_end(funchook, &mstate); + if (rv != 0) { + return rv; + } + flush_instruction_cache(entry->target_func, JUMP32_BYTE_SIZE);") + string(FIND "${_content}" "${_original}" _position) + if (_position EQUAL -1) + message(FATAL_ERROR "Funchook ${_operation} patch site changed") + endif () + string(REPLACE "${_original}" "#ifdef __APPLE__ + int rv = commit_code_patch(entry->target_func, entry->${_expected}, + entry->${_replacement}, JUMP32_BYTE_SIZE); + if (rv != 0) { + funchook_set_error_message(funchook, \"Code patch commit failed (Mach error %d)\", rv); + return FUNCHOOK_ERROR_MEMORY_FUNCTION; + } +#else +${_original} +#endif" _content "${_content}") + endforeach () + file(WRITE "${SOURCE_DIR}/src/funchook.c" "${_content}") +endif () diff --git a/extern/borealis b/extern/borealis index f55910bd79..0bdba6c50a 160000 --- a/extern/borealis +++ b/extern/borealis @@ -1 +1 @@ -Subproject commit f55910bd79248db250ebd9debb6624d78538c21c +Subproject commit 0bdba6c50a46409c4862474c72b4a3a631fbe0ec diff --git a/files.cmake b/files.cmake index b51389d40f..42a12c353c 100644 --- a/files.cmake +++ b/files.cmake @@ -1484,6 +1484,12 @@ set(DUSK_FILES src/dusk/mods/loader/depgraph.hpp src/dusk/mods/loader/loader.cpp src/dusk/mods/loader/loader.hpp + src/dusk/mods/loader/manifest.cpp + src/dusk/mods/loader/manifest.hpp + src/dusk/mods/loader/natives.cpp + src/dusk/mods/loader/natives.hpp + src/dusk/mods/loader/packages.cpp + src/dusk/mods/loader/packages.hpp src/dusk/mods/loader/native_module.cpp src/dusk/mods/loader/native_module.hpp src/dusk/mods/loader/prepatch.cpp @@ -1601,6 +1607,12 @@ set(DUSK_FILES src/dusk/ui/nav_types.hpp src/dusk/ui/nav_group.cpp src/dusk/ui/nav_group.hpp + src/dusk/ui/context_menu.cpp + src/dusk/ui/context_menu.hpp + src/dusk/ui/icon_button.cpp + src/dusk/ui/icon_button.hpp + src/dusk/ui/tooltip.cpp + src/dusk/ui/tooltip.hpp src/dusk/ui/number_button.cpp src/dusk/ui/number_button.hpp src/dusk/ui/overlay.cpp diff --git a/res/rml/mod_browser.rcss b/res/rml/mod_browser.rcss index b66b28701b..e88285b019 100644 --- a/res/rml/mod_browser.rcss +++ b/res/rml/mod_browser.rcss @@ -118,10 +118,9 @@ catalog-card-art { position: relative; flex: 0 0 118dp; min-height: 118dp; - background-color: rgba(var(--color-border-rgb), 12%); } -catalog-card-art-shadow { +catalog-card-art-image { display: block; position: absolute; top: 0; @@ -129,7 +128,7 @@ catalog-card-art-shadow { bottom: 0; left: 0; pointer-events: none; - decorator: linear-gradient(180deg, rgba(var(--color-surface-rgb), 0%) 45%, rgba(var(--color-surface-rgb), 85%)); + mask-image: linear-gradient(180deg, #fff 45%, transparent); } catalog-card-body { @@ -182,7 +181,6 @@ catalog-card-body > small { height: 56dp; border-radius: var(--radius-panel); overflow: hidden; - background-color: rgba(var(--color-border-rgb), 18%); box-shadow: rgba(var(--color-black-rgb), 60%) 0 6dp 16dp; } @@ -235,8 +233,10 @@ catalog-card-body > footer { catalog-card-body > footer stat { display: flex; + flex: 0 0 auto; align-items: center; gap: 3dp; + white-space: nowrap; } catalog-card-body > footer icon { @@ -329,10 +329,9 @@ catalog-detail-hero { flex: 0 0 220dp; min-height: 220dp; padding: 18dp var(--space-xl) 20dp var(--space-xl); - background-color: rgba(var(--color-control-rgb), 75%); } -catalog-detail-hero-shadow { +catalog-detail-hero-image { display: block; position: absolute; top: 0; @@ -340,7 +339,7 @@ catalog-detail-hero-shadow { bottom: 0; left: 0; pointer-events: none; - decorator: linear-gradient(180deg, rgba(var(--color-surface-rgb), 25%) 40%, rgba(var(--color-surface-rgb), 92%)); + mask-image: linear-gradient(180deg, #fff 40%, transparent); } catalog-detail-actions { @@ -403,10 +402,11 @@ catalog-source-actions button { } .catalog-install-action.idle { - --button-background: rgba(var(--color-interactive-rgb), 40%); - --button-background-hover: rgba(var(--color-interactive-rgb), 55%); - --button-background-selected: rgba(var(--color-interactive-rgb), 55%); - --button-background-active: rgba(var(--color-interactive-rgb), 55%); + --button-background: rgba(var(--color-interactive-rgb), 18%); + --button-background-hover: rgba(var(--color-interactive-rgb), 42%); + --button-background-selected: rgba(var(--color-interactive-rgb), 42%); + --button-background-active: rgba(var(--color-interactive-rgb), 65%); + box-shadow: rgba(var(--color-accent-rgb), 65%) 0 0 0 1dp; } .catalog-install-action.paused { @@ -467,10 +467,6 @@ catalog-source-actions button { box-shadow: rgba(var(--color-success-rgb), 50%) 0 0 0 2dp; } -.catalog-install-action.installed:disabled { - opacity: 1; -} - .catalog-install-action.installing progress fill { background-color: rgba(var(--color-info-rgb), 80%); } @@ -513,7 +509,6 @@ catalog-detail-identity mod-icon { height: 70dp; border-radius: var(--radius-panel); overflow: hidden; - background-color: rgba(var(--color-border-rgb), 25%); box-shadow: rgba(var(--color-black-rgb), 65%) 0 8dp 20dp; } @@ -561,11 +556,7 @@ catalog-detail-stats { flex-flow: row; align-items: center; gap: 28dp; - padding: 13dp 28dp; - border-top-width: 1dp; - border-top-color: rgba(var(--color-border-rgb), 30%); - border-bottom-width: 1dp; - border-bottom-color: rgba(var(--color-border-rgb), 30%); + margin: 0 var(--space-xl); font-size: var(--font-size-2xs); color: rgba(var(--color-text-rgb), 58%); } @@ -582,11 +573,6 @@ catalog-detail-stats icon { line-height: 1; } -catalog-detail-stats > stat > b { - color: var(--color-text); - font-weight: bold; -} - catalog-detail-body { display: flex; flex-flow: row; @@ -705,6 +691,8 @@ catalog-gallery { } .catalog-screenshot { + position: relative; + overflow: hidden; flex: 1 1 0; height: 100%; min-width: 0; @@ -718,6 +706,25 @@ catalog-gallery { flex: 2 1 0; } +catalog-screenshot-image, +catalog-screenshot-more { + display: block; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; +} + +catalog-screenshot-more { + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(var(--color-black-rgb), 60%); + color: var(--color-white); +} + catalog-dependencies { display: flex; flex-flow: column; @@ -759,13 +766,14 @@ catalog-detail-body dl { display: flex; flex-flow: row wrap; margin: 0; - font-size: var(--font-size-2xs); + font-size: var(--font-size-sm); } catalog-detail-body dt { flex: 0 0 42%; padding: 5dp 0; - opacity: 0.5; + font-weight: bold; + opacity: 0.8; } catalog-detail-body dd { @@ -775,11 +783,6 @@ catalog-detail-body dd { text-align: right; } -catalog-source-actions { - display: flex; - flex-flow: column; -} - window.screenshot-viewer > content { flex-flow: column; padding: 18dp; diff --git a/res/rml/mods.rcss b/res/rml/mods.rcss index 264b166e3a..acd6d923a3 100644 --- a/res/rml/mods.rcss +++ b/res/rml/mods.rcss @@ -199,6 +199,22 @@ mod-header.has-banner { margin: -24dp -24dp 0dp -24dp; } +mod-header-image { + display: block; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; + mask-image: linear-gradient(180deg, #fff 40%, transparent); + filter: grayscale(0); +} + +mod-header.inactive mod-header-image { + filter: grayscale(1); +} + mod-actions { position: absolute; top: 24dp; @@ -209,9 +225,10 @@ mod-actions { } mod-actions button { - font-size: var(--font-size-md); - padding: 6dp 14dp; - background-color: rgba(var(--color-surface-rgb), 80%); + --button-background: rgba(var(--color-surface-rgb), 80%); + --button-background-hover: rgba(var(--color-control-rgb), 90%); + --button-background-selected: rgba(var(--color-control-rgb), 90%); + --button-background-active: rgba(var(--color-surface-rgb), 90%); box-shadow: rgba(var(--color-border-rgb), 60%) 0 0 0 1dp; } diff --git a/res/rml/popover.rcss b/res/rml/popover.rcss index 789e821564..2084b132f0 100644 --- a/res/rml/popover.rcss +++ b/res/rml/popover.rcss @@ -186,3 +186,65 @@ color-value:hover, color-value:focus-visible { color: var(--color-accent); } + +popover.context-menu { + min-width: 200dp; + max-width: 90%; + max-height: 90%; + overflow-y: auto; + padding: 6dp; + gap: 2dp; + transform-origin: left top; +} + +.context-menu button { + display: flex; + align-items: center; + gap: 10dp; + padding: 8dp 12dp; + flex: 0 0 auto; + white-space: nowrap; + --button-background: transparent; + background-color: var(--button-background); + box-shadow: none; + transition: background-color 0.1s linear-in-out; +} + +.context-menu button:not(:disabled):hover, +.context-menu button:not(:disabled):focus-visible { + background-color: var(--button-background-hover); + box-shadow: none; +} + +.context-menu button:not(:disabled):active { + background-color: var(--button-background-active); +} + +.context-menu button:disabled { + background-color: transparent; + box-shadow: none; + opacity: 0.4; + cursor: unavailable; +} + +.context-menu button.destructive { + color: var(--color-error); +} + +.context-menu icon { + display: block; + flex: 0 0 1em; + width: 1em; + height: 1em; + font-family: var(--font-family-icons); + font-weight: normal; + line-height: 1; +} + +menu-separator { + display: block; + flex: 0 0 1dp; + height: 1dp; + margin: 4dp 6dp; + background-color: var(--color-border); +} diff --git a/res/rml/tabbing.rcss b/res/rml/tabbing.rcss index c508a354d5..862d79a5df 100644 --- a/res/rml/tabbing.rcss +++ b/res/rml/tabbing.rcss @@ -57,7 +57,7 @@ window > close { position: fixed; top: 8dp; right: 8dp; - z-index: 1; + z-index: 2; width: 48dp; height: 48dp; font-family: var(--font-family-icons); diff --git a/res/rml/window.rcss b/res/rml/window.rcss index ccabe49238..f2f2574750 100644 --- a/res/rml/window.rcss +++ b/res/rml/window.rcss @@ -940,3 +940,42 @@ modal-actions.vertical button.modal-btn { width: 100%; } } + +button.icon-button { + display: flex; + align-items: center; + justify-content: center; + width: 44dp; + height: 44dp; + padding: var(--space-sm); + box-sizing: border-box; + flex-shrink: 0; + font-size: var(--font-size-5xl); +} + +button.icon-button icon { + display: block; + flex-shrink: 0; + line-height: 1; + pointer-events: none; +} + +ui-tooltip { + display: none; + position: absolute; + z-index: 1000; + max-width: 240dp; + padding: 6dp 10dp; + border: 1dp var(--color-border); + border-radius: var(--radius-panel); + background-color: rgba(var(--color-surface-rgb), 96%); + color: var(--color-text); + font-size: var(--font-size-md); + word-break: break-word; + pointer-events: none; + focus: none; +} + +ui-tooltip.visible { + display: block; +} diff --git a/sdk/include/mods/api.h b/sdk/include/mods/api.h index 3fde6c9f6a..3015130ae3 100644 --- a/sdk/include/mods/api.h +++ b/sdk/include/mods/api.h @@ -42,6 +42,8 @@ extern "C" { #define MOD_ABI_VERSION 1u #define MOD_ERROR_MESSAGE_SIZE 512u +#define DUSKLIGHT_SERVICE_ID_PREFIX "dev.twilitrealm.dusklight." + typedef struct ModContext ModContext; typedef enum ModResult { diff --git a/sdk/include/mods/svc/actor.h b/sdk/include/mods/svc/actor.h index 5876ddcaf2..0048b70332 100644 --- a/sdk/include/mods/svc/actor.h +++ b/sdk/include/mods/svc/actor.h @@ -3,7 +3,7 @@ #include #include -#define ACTOR_SERVICE_ID "dev.twilitrealm.dusklight.actor" +#define ACTOR_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "actor" #define ACTOR_SERVICE_MAJOR 1u #define ACTOR_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/camera.h b/sdk/include/mods/svc/camera.h index 1dccecfba6..a901c2b739 100644 --- a/sdk/include/mods/svc/camera.h +++ b/sdk/include/mods/svc/camera.h @@ -6,7 +6,7 @@ #include #endif -#define CAMERA_SERVICE_ID "dev.twilitrealm.dusklight.camera" +#define CAMERA_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "camera" #define CAMERA_SERVICE_MAJOR 1u #define CAMERA_SERVICE_MINOR 1u diff --git a/sdk/include/mods/svc/config.h b/sdk/include/mods/svc/config.h index fb04f6e9ea..55a0bca131 100644 --- a/sdk/include/mods/svc/config.h +++ b/sdk/include/mods/svc/config.h @@ -6,7 +6,7 @@ #include #endif -#define CONFIG_SERVICE_ID "dev.twilitrealm.dusklight.config" +#define CONFIG_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "config" #define CONFIG_SERVICE_MAJOR 1u #define CONFIG_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/file.h b/sdk/include/mods/svc/file.h index 4d5c82b5a1..0383c54066 100644 --- a/sdk/include/mods/svc/file.h +++ b/sdk/include/mods/svc/file.h @@ -6,7 +6,7 @@ #include #endif -#define FILE_SERVICE_ID "dev.twilitrealm.dusklight.file" +#define FILE_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "file" #define FILE_SERVICE_MAJOR 1u #define FILE_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/flow.h b/sdk/include/mods/svc/flow.h index a9028df0a4..1099d86fd9 100644 --- a/sdk/include/mods/svc/flow.h +++ b/sdk/include/mods/svc/flow.h @@ -6,7 +6,7 @@ #include #endif -#define FLOW_SERVICE_ID "dev.twilitrealm.dusklight.flow" +#define FLOW_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "flow" #define FLOW_SERVICE_MAJOR 1u #define FLOW_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/game.h b/sdk/include/mods/svc/game.h index 32de49903f..61a10968e6 100644 --- a/sdk/include/mods/svc/game.h +++ b/sdk/include/mods/svc/game.h @@ -15,7 +15,7 @@ * ordinary version check then fails mods built against the old epoch with a clear message instead * of letting them corrupt memory. */ -#define GAME_SERVICE_ID "dev.twilitrealm.dusklight.game" +#define GAME_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "game" #define GAME_SERVICE_MAJOR 2u #define GAME_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/game_mode.h b/sdk/include/mods/svc/game_mode.h index 8ccc5d64f1..d17a8f003c 100644 --- a/sdk/include/mods/svc/game_mode.h +++ b/sdk/include/mods/svc/game_mode.h @@ -3,7 +3,7 @@ #include #include -#define GAME_MODE_SERVICE_ID "dev.twilitrealm.dusklight.gamemode" +#define GAME_MODE_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "gamemode" #define GAME_MODE_SERVICE_MAJOR 1u #define GAME_MODE_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/gfx.h b/sdk/include/mods/svc/gfx.h index 0eee026984..787958716b 100644 --- a/sdk/include/mods/svc/gfx.h +++ b/sdk/include/mods/svc/gfx.h @@ -31,7 +31,7 @@ * should be released in mod_shutdown. The device outlives all mods. */ -#define GFX_SERVICE_ID "dev.twilitrealm.dusklight.gfx" +#define GFX_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "gfx" #define GFX_SERVICE_MAJOR 1u #define GFX_SERVICE_MINOR 2u diff --git a/sdk/include/mods/svc/hook.h b/sdk/include/mods/svc/hook.h index 288db3cc14..30b199b386 100644 --- a/sdk/include/mods/svc/hook.h +++ b/sdk/include/mods/svc/hook.h @@ -20,7 +20,7 @@ * (file-local statics included). */ -#define HOOK_SERVICE_ID "dev.twilitrealm.dusklight.hook" +#define HOOK_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "hook" #define HOOK_SERVICE_MAJOR 1u #define HOOK_SERVICE_MINOR 1u diff --git a/sdk/include/mods/svc/host.h b/sdk/include/mods/svc/host.h index 45d89eea3e..2b751f34c3 100644 --- a/sdk/include/mods/svc/host.h +++ b/sdk/include/mods/svc/host.h @@ -11,7 +11,7 @@ * Always available; every other service can be reached from it. */ -#define HOST_SERVICE_ID "dev.twilitrealm.dusklight.host" +#define HOST_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "host" #define HOST_SERVICE_MAJOR 2u #define HOST_SERVICE_MINOR 2u diff --git a/sdk/include/mods/svc/http.h b/sdk/include/mods/svc/http.h index fd4d9a98db..2c880b3b80 100644 --- a/sdk/include/mods/svc/http.h +++ b/sdk/include/mods/svc/http.h @@ -6,7 +6,7 @@ #include #endif -#define HTTP_SERVICE_ID "dev.twilitrealm.dusklight.http" +#define HTTP_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "http" #define HTTP_SERVICE_MAJOR 1u #define HTTP_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/item.h b/sdk/include/mods/svc/item.h index ff4f31b547..58cf7743e4 100644 --- a/sdk/include/mods/svc/item.h +++ b/sdk/include/mods/svc/item.h @@ -6,7 +6,7 @@ #include #endif -#define ITEM_SERVICE_ID "dev.twilitrealm.dusklight.item" +#define ITEM_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "item" #define ITEM_SERVICE_MAJOR 2u #define ITEM_SERVICE_MINOR 3u diff --git a/sdk/include/mods/svc/log.h b/sdk/include/mods/svc/log.h index 1040710492..2401b4dccf 100644 --- a/sdk/include/mods/svc/log.h +++ b/sdk/include/mods/svc/log.h @@ -11,7 +11,7 @@ * (prefixed with its ID). */ -#define LOG_SERVICE_ID "dev.twilitrealm.dusklight.log" +#define LOG_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "log" #define LOG_SERVICE_MAJOR 1u #define LOG_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/message.h b/sdk/include/mods/svc/message.h index b170e6f09b..d3833d904e 100644 --- a/sdk/include/mods/svc/message.h +++ b/sdk/include/mods/svc/message.h @@ -6,7 +6,7 @@ #include #endif -#define MESSAGE_SERVICE_ID "dev.twilitrealm.dusklight.message" +#define MESSAGE_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "message" #define MESSAGE_SERVICE_MAJOR 1u #define MESSAGE_SERVICE_MINOR 1u diff --git a/sdk/include/mods/svc/net.h b/sdk/include/mods/svc/net.h index 2c5e9a531a..9202ff50ad 100644 --- a/sdk/include/mods/svc/net.h +++ b/sdk/include/mods/svc/net.h @@ -6,7 +6,7 @@ #include #endif -#define NET_SERVICE_ID "dev.twilitrealm.dusklight.net" +#define NET_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "net" #define NET_SERVICE_MAJOR 1u #define NET_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/overlay.h b/sdk/include/mods/svc/overlay.h index ea46bc8798..ee4318ea4f 100644 --- a/sdk/include/mods/svc/overlay.h +++ b/sdk/include/mods/svc/overlay.h @@ -6,7 +6,7 @@ #include #endif -#define OVERLAY_SERVICE_ID "dev.twilitrealm.dusklight.overlay" +#define OVERLAY_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "overlay" #define OVERLAY_SERVICE_MAJOR 1u #define OVERLAY_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/resource.h b/sdk/include/mods/svc/resource.h index 60b609d2a2..516dcab20a 100644 --- a/sdk/include/mods/svc/resource.h +++ b/sdk/include/mods/svc/resource.h @@ -12,7 +12,7 @@ * for temporary storage. */ -#define RESOURCE_SERVICE_ID "dev.twilitrealm.dusklight.resource" +#define RESOURCE_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "resource" #define RESOURCE_SERVICE_MAJOR 1u #define RESOURCE_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/save.h b/sdk/include/mods/svc/save.h index 0d6943f96a..d9eb7432be 100644 --- a/sdk/include/mods/svc/save.h +++ b/sdk/include/mods/svc/save.h @@ -6,7 +6,7 @@ #include #endif -#define SAVE_SERVICE_ID "dev.twilitrealm.dusklight.save" +#define SAVE_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "save" #define SAVE_SERVICE_MAJOR 1u #define SAVE_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/stage.h b/sdk/include/mods/svc/stage.h index a9f8198919..9dbd94cff1 100644 --- a/sdk/include/mods/svc/stage.h +++ b/sdk/include/mods/svc/stage.h @@ -6,7 +6,7 @@ #include #endif -#define STAGE_SERVICE_ID "dev.twilitrealm.dusklight.stage" +#define STAGE_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "stage" #define STAGE_SERVICE_MAJOR 1u #define STAGE_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/texture.h b/sdk/include/mods/svc/texture.h index dac73fbade..0de813cd04 100644 --- a/sdk/include/mods/svc/texture.h +++ b/sdk/include/mods/svc/texture.h @@ -6,7 +6,7 @@ #include #endif -#define TEXTURE_SERVICE_ID "dev.twilitrealm.dusklight.texture" +#define TEXTURE_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "texture" #define TEXTURE_SERVICE_MAJOR 1u #define TEXTURE_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/ui.h b/sdk/include/mods/svc/ui.h index d0badfda05..bcac0c53fa 100644 --- a/sdk/include/mods/svc/ui.h +++ b/sdk/include/mods/svc/ui.h @@ -8,7 +8,7 @@ #include #endif -#define UI_SERVICE_ID "dev.twilitrealm.dusklight.ui" +#define UI_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "ui" #define UI_SERVICE_MAJOR 2u #define UI_SERVICE_MINOR 2u diff --git a/sdk/include/mods/svc/websocket.h b/sdk/include/mods/svc/websocket.h index bfc12d8fca..abd36410ef 100644 --- a/sdk/include/mods/svc/websocket.h +++ b/sdk/include/mods/svc/websocket.h @@ -7,7 +7,7 @@ #include #endif -#define WEBSOCKET_SERVICE_ID "dev.twilitrealm.dusklight.websocket" +#define WEBSOCKET_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "websocket" #define WEBSOCKET_SERVICE_MAJOR 1u #define WEBSOCKET_SERVICE_MINOR 0u diff --git a/sdk/include/mods/svc/window.h b/sdk/include/mods/svc/window.h index 7cee8a5abe..843e388ed8 100644 --- a/sdk/include/mods/svc/window.h +++ b/sdk/include/mods/svc/window.h @@ -8,7 +8,7 @@ #include -#define WINDOW_SERVICE_ID "dev.twilitrealm.dusklight.window" +#define WINDOW_SERVICE_ID DUSKLIGHT_SERVICE_ID_PREFIX "window" #define WINDOW_SERVICE_MAJOR 1u #define WINDOW_SERVICE_MINOR 0u diff --git a/src/dusk/archive.hpp b/src/dusk/archive.hpp index 4fe3196199..2e1003d49c 100644 --- a/src/dusk/archive.hpp +++ b/src/dusk/archive.hpp @@ -13,9 +13,9 @@ namespace dusk::archive { enum class PackageFormat { Unknown, Mod, + // Save, }; -/** File-backed ZIP reader shared by Dusklight package formats. */ class ZipArchive { public: explicit ZipArchive(const std::filesystem::path& path); diff --git a/src/dusk/mod_loader.hpp b/src/dusk/mod_loader.hpp index f23c7c1a9d..fc419e7367 100644 --- a/src/dusk/mod_loader.hpp +++ b/src/dusk/mod_loader.hpp @@ -208,8 +208,11 @@ struct LoadedMod { std::string dataDirUtf8; uint32_t searchDirIndex = 0; - // Native lib is dlopen'd in place and stays resident for the session. Reload is unsupported. + bool fromDirectory = false; + // Native lib is dlopen'd in place. bool nativeInPlace = false; + bool hasUserPackage = false; + bool hasBundledCopy = false; FileIdentity fileIdentity; std::unique_ptr> cvarIsEnabled; @@ -259,6 +262,8 @@ struct LoadedMod { [[nodiscard]] bool activation_failed() const { return loadFailed || (is_enabled() && !active); } }; +struct PackageCandidate; + class ModLoader { public: static ModLoader& instance(); @@ -279,6 +284,7 @@ public: [[nodiscard]] std::filesystem::path user_mods_dir() const; [[nodiscard]] bool can_uninstall(const LoadedMod& mod) const; + [[nodiscard]] bool can_update(const LoadedMod& mod) const; [[nodiscard]] LoadedMod* find_mod(std::string_view id); [[nodiscard]] const LoadedMod* find_mod(std::string_view id) const; [[nodiscard]] uint64_t generation() const noexcept { return m_generation; } @@ -359,14 +365,17 @@ private: void apply_pending_requests(); [[nodiscard]] OperationResult install_staged(const std::filesystem::path& path); [[nodiscard]] OperationResult load_runtime_mod(const std::filesystem::path& path); - [[nodiscard]] OperationResult reload_runtime_mod(LoadedMod& mod); + [[nodiscard]] OperationResult reload_runtime_mod( + LoadedMod& mod, const PackageCandidate* replacement = nullptr); + [[nodiscard]] OperationResult uninstall_runtime_mod(LoadedMod& mod); [[nodiscard]] OperationResult runtime_result(LoadedMod& mod); void forget_mod(LoadedMod& mod); void flush_toasts(); void on_enabled_changed(LoadedMod& mod); // Deactivates `target` (if needed) and its transitive dependents, optionally re-reads the // bundle from disk, then reactivates whatever the current cvar/provider state allows. - void apply_lifecycle_change(LoadedMod& target, bool reload); + void apply_lifecycle_change( + LoadedMod& target, bool reload, const PackageCandidate* replacement = nullptr); // `target` plus transitive active/suspended dependents, in m_mods (init) order. std::vector collect_lifecycle_set(LoadedMod& target) const; void resume_lifecycle_set(const std::vector& mods); @@ -374,7 +383,6 @@ private: bool ensure_native_loaded(LoadedMod& mod); }; -// Reads and validates mod.json without loading native code or changing loader state. bool inspect_mod_bundle(const std::filesystem::path& path, ModMetadata& metadata, std::string& error, bool* hasNative = nullptr) noexcept; diff --git a/src/dusk/mods/loader/code_patch_macos.cpp b/src/dusk/mods/loader/code_patch_macos.cpp new file mode 100644 index 0000000000..64a7b4a606 --- /dev/null +++ b/src/dusk/mods/loader/code_patch_macos.cpp @@ -0,0 +1,223 @@ +#include "code_patch_macos.hpp" + +#include +#include +#include +#include +#include +#include + +#define PATCH_CODE \ + __attribute__((section("__TEXT,__code_patch,regular,pure_instructions"), noinline)) + +extern const char kPatchBegin[] asm("section$start$__TEXT$__code_patch"); +extern const char kPatchEnd[] asm("section$end$__TEXT$__code_patch"); + +namespace { + +constexpr unsigned kMaxThreads = 1024; +constexpr unsigned kMaxAttempts = 16; +constexpr size_t kMaxPatchSize = 16; +pthread_mutex_t sPatchMutex = PTHREAD_MUTEX_INITIALIZER; + +struct ThreadList { + thread_act_array_t threads = nullptr; + mach_msg_type_number_t count = 0; +}; + +PATCH_CODE void release_threads(ThreadList& list) { + for (unsigned i = 0; i < list.count; ++i) { + mach_port_deallocate(mach_task_self(), list.threads[i]); + } + if (list.threads != nullptr) { + vm_deallocate(mach_task_self(), reinterpret_cast(list.threads), + list.count * sizeof(thread_t)); + } +} + +PATCH_CODE kern_return_t read_pc(thread_t thread, uintptr_t& pc) { +#if defined(__aarch64__) + arm_thread_state64_t state{}; + mach_msg_type_number_t count = ARM_THREAD_STATE64_COUNT; + const auto result = thread_get_state( + thread, ARM_THREAD_STATE64, reinterpret_cast(&state), &count); + pc = arm_thread_state64_get_pc(state); +#elif defined(__x86_64__) + x86_thread_state64_t state{}; + mach_msg_type_number_t count = x86_THREAD_STATE64_COUNT; + const auto result = thread_get_state( + thread, x86_THREAD_STATE64, reinterpret_cast(&state), &count); + pc = state.__rip; +#else +#error Unsupported macOS architecture +#endif + return result; +} + +PATCH_CODE __attribute__((aligned(16384))) kern_return_t commit_patch(uintptr_t target, + const unsigned char* expected, const unsigned char* replacement, size_t size, uintptr_t page, + size_t pageSize, thread_t currentThread) { + ThreadList initial; + auto result = task_threads(mach_task_self(), &initial.threads, &initial.count); + if (result != KERN_SUCCESS) { + return result; + } + thread_t suspended[kMaxThreads]; + unsigned suspendedCount = 0; + if (initial.count > kMaxThreads) { + release_threads(initial); + return KERN_RESOURCE_SHORTAGE; + } + + for (unsigned i = 0; i < initial.count; ++i) { + const auto thread = initial.threads[i]; + if (thread == currentThread) { + continue; + } + result = thread_suspend(thread); + if (result != KERN_SUCCESS) { + break; + } + suspended[suspendedCount++] = thread; + uintptr_t pc = 0; + result = read_pc(thread, pc); + if (result != KERN_SUCCESS) { + break; + } + if (pc >= target && pc < target + size) { + result = KERN_ABORTED; + break; + } + } + + if (result == KERN_SUCCESS) { + ThreadList current; + result = task_threads(mach_task_self(), ¤t.threads, ¤t.count); + if (result == KERN_SUCCESS) { + for (unsigned i = 0; i < current.count; ++i) { + bool known = false; + for (unsigned j = 0; j < initial.count; ++j) { + known |= current.threads[i] == initial.threads[j]; + } + if (!known) { + result = KERN_ABORTED; + break; + } + } + } + release_threads(current); + } + + if (result == KERN_SUCCESS) { + const auto* bytes = reinterpret_cast(target); + for (size_t i = 0; i < size; ++i) { + if (bytes[i] != expected[i]) { + result = KERN_INVALID_VALUE; + break; + } + } + } + + if (result == KERN_SUCCESS) { + result = mach_vm_protect( + mach_task_self(), page, pageSize, false, VM_PROT_READ | VM_PROT_WRITE | VM_PROT_COPY); + if (result == KERN_SUCCESS) { + auto* bytes = reinterpret_cast(target); + for (size_t i = 0; i < size; ++i) { + bytes[i] = replacement[i]; + } + sys_icache_invalidate(reinterpret_cast(target), size); + result = mach_vm_protect( + mach_task_self(), page, pageSize, false, VM_PROT_READ | VM_PROT_EXECUTE); + if (result != KERN_SUCCESS) { + for (size_t i = 0; i < size; ++i) { + bytes[i] = expected[i]; + } + sys_icache_invalidate(reinterpret_cast(target), size); + if (mach_vm_protect(mach_task_self(), page, pageSize, false, + VM_PROT_READ | VM_PROT_EXECUTE) != KERN_SUCCESS) + { + __builtin_trap(); // Can't resume into non-executable code + } + } + } + } + + for (unsigned i = 0; i < suspendedCount; ++i) { + if (thread_resume(suspended[i]) != KERN_SUCCESS) { + __builtin_trap(); + } + } + release_threads(initial); + return result; +} + +} // namespace + +extern "C" int commit_code_patch( + void* targetPointer, const void* expected, const void* replacement, size_t size) { + if (targetPointer == nullptr || expected == nullptr || replacement == nullptr || size == 0 || + size > kMaxPatchSize) + { + return KERN_INVALID_ARGUMENT; + } + const auto target = reinterpret_cast(targetPointer); + const size_t pageSize = vm_page_size; + if (target > UINTPTR_MAX - size - pageSize) { + return KERN_INVALID_ADDRESS; + } + const auto page = target & ~(pageSize - 1); + const size_t length = ((target + size + pageSize - 1) & ~(pageSize - 1)) - page; + if (page < reinterpret_cast(kPatchEnd) && + page + length > reinterpret_cast(kPatchBegin)) + { + return KERN_PROTECTION_FAILURE; + } + + unsigned char oldCode[kMaxPatchSize]; + unsigned char newCode[kMaxPatchSize]; + for (size_t i = 0; i < size; ++i) { + oldCode[i] = static_cast(expected)[i]; + newCode[i] = static_cast(replacement)[i]; + } + + pthread_mutex_lock(&sPatchMutex); + mach_vm_address_t region = page; + mach_vm_size_t regionSize = 0; + vm_region_basic_info_data_64_t info{}; + mach_msg_type_number_t count = VM_REGION_BASIC_INFO_COUNT_64; + mach_port_t object = MACH_PORT_NULL; + auto result = mach_vm_region(mach_task_self(), ®ion, ®ionSize, VM_REGION_BASIC_INFO_64, + reinterpret_cast(&info), &count, &object); + if (object != MACH_PORT_NULL) { + mach_port_deallocate(mach_task_self(), object); + } + if (result == KERN_SUCCESS && (region > page || regionSize < page + length - region || + info.protection != (VM_PROT_READ | VM_PROT_EXECUTE))) + { + result = KERN_PROTECTION_FAILURE; + } + if (result == KERN_SUCCESS) { + const auto currentThread = mach_thread_self(); + uintptr_t pc = 0; + read_pc(currentThread, pc); + thread_suspend(MACH_PORT_NULL); + thread_resume(MACH_PORT_NULL); + vm_deallocate(mach_task_self(), 0, 0); + mach_port_deallocate(mach_task_self(), MACH_PORT_NULL); + sys_icache_invalidate(targetPointer, size); + result = mach_vm_protect(mach_task_self(), page, length, false, info.protection); + if (result == KERN_SUCCESS) { + for (unsigned attempt = 0; attempt < kMaxAttempts; ++attempt) { + result = commit_patch(target, oldCode, newCode, size, page, length, currentThread); + if (result != KERN_ABORTED || attempt + 1 == kMaxAttempts) { + break; + } + usleep(1000); + } + } + mach_port_deallocate(mach_task_self(), currentThread); + } + pthread_mutex_unlock(&sPatchMutex); + return result; +} diff --git a/src/dusk/mods/loader/code_patch_macos.hpp b/src/dusk/mods/loader/code_patch_macos.hpp new file mode 100644 index 0000000000..540e806404 --- /dev/null +++ b/src/dusk/mods/loader/code_patch_macos.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +int commit_code_patch(void* target, const void* expected, const void* replacement, size_t size); + +#ifdef __cplusplus +} +#endif diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp index 5890d8165e..16c49f8c45 100644 --- a/src/dusk/mods/loader/loader.cpp +++ b/src/dusk/mods/loader/loader.cpp @@ -1,18 +1,20 @@ #include "loader.hpp" -#include "../manifest.hpp" #include "depgraph.hpp" +#include "manifest.hpp" #include "native_module.hpp" +#include "natives.hpp" +#include "packages.hpp" #if DUSK_HAS_PREPATCH #include "prepatch.hpp" #endif #include "dusk/config.hpp" #include "dusk/data.hpp" -#include "dusk/io.hpp" #include "dusk/logging.h" #include "dusk/mod_loader.hpp" #include "dusk/mods/log_buffer.hpp" +#include "dusk/mods/manifest.hpp" #include "dusk/mods/path.hpp" #include "dusk/mods/queue.hpp" #include "dusk/mods/svc/config.hpp" @@ -23,181 +25,23 @@ #include "dusk/ui/ui.hpp" #include +#include #include -#include #include -#include #include -#include #include -#include #include #include #include #include -using namespace std::string_literals; -using namespace std::string_view_literals; namespace fs = std::filesystem; -#if defined(_WIN32) -#if defined(_M_ARM64) -static constexpr std::string_view k_nativePlatform = "windows-arm64"sv; -#elif defined(_M_X64) -static constexpr std::string_view k_nativePlatform = "windows-amd64"sv; -#elif defined(_M_IX86) -static constexpr std::string_view k_nativePlatform = "windows-x86"sv; -#else -static constexpr std::string_view k_nativePlatform = ""sv; -#endif -static constexpr std::string_view k_nativeLibName = "mod.dll"sv; -#elif defined(__ANDROID__) -#if defined(__aarch64__) -static constexpr std::string_view k_nativePlatform = "android-aarch64"sv; -#elif defined(__x86_64__) -static constexpr std::string_view k_nativePlatform = "android-x86_64"sv; -#else -static constexpr std::string_view k_nativePlatform = ""sv; -#endif -static constexpr std::string_view k_nativeLibName = "mod.so"sv; -#elif defined(__APPLE__) -#include -#if TARGET_OS_IOS -static constexpr std::string_view k_nativePlatform = "ios-arm64"sv; -#elif TARGET_OS_TV -static constexpr std::string_view k_nativePlatform = "tvos-arm64"sv; -#elif defined(__aarch64__) -static constexpr std::string_view k_nativePlatform = "macos-arm64"sv; -#elif defined(__x86_64__) -static constexpr std::string_view k_nativePlatform = "macos-x86_64"sv; -#else -static constexpr std::string_view k_nativePlatform = ""sv; -#endif -static constexpr std::string_view k_nativeLibName = "mod.so"sv; -#elif defined(__linux__) -#if defined(__aarch64__) -static constexpr std::string_view k_nativePlatform = "linux-aarch64"sv; -#elif defined(__x86_64__) -static constexpr std::string_view k_nativePlatform = "linux-x86_64"sv; -#elif defined(__i386__) -static constexpr std::string_view k_nativePlatform = "linux-x86"sv; -#else -static constexpr std::string_view k_nativePlatform = ""sv; -#endif -static constexpr std::string_view k_nativeLibName = "mod.so"sv; -#else -static constexpr std::string_view k_nativePlatform = ""sv; -static constexpr std::string_view k_nativeLibName = ""sv; -#endif - namespace dusk::mods { namespace { constexpr borealis::Log Log{"dusk::mods::loader"}; ModLoader g_modLoader; -constexpr std::string_view k_nativeLibDir = "lib/"sv; - -class DirectoryRollback { -public: - ~DirectoryRollback() { - if (!mPath.empty()) { - std::error_code ec; - fs::remove_all(mPath, ec); - } - } - - void set_path(fs::path path) { mPath = std::move(path); } - void release() { mPath.clear(); } - -private: - fs::path mPath; -}; - -std::unique_ptr load_bundle(const fs::path& modPath, bool fromDir) { - if (fromDir) { - return std::make_unique(modPath); - } else { - return std::make_unique(modPath); - } -} - -struct NativeRuntimeLocation { - std::string entry; - std::vector runtimeEntries; - bool anyLibs = false; -}; - -struct NativeLocateFailure { - NativeModStatus status; - std::string logMessage; -}; - -using NativeLocateResult = std::variant; - -bool has_native_library_extension(std::string_view name) { - const auto endsWith = [name](std::string_view extension) { - if (name.size() < extension.size()) { - return false; - } - const auto suffix = name.substr(name.size() - extension.size()); - return std::ranges::equal(suffix, extension, [](char lhs, char rhs) { - const auto lower = [](char value) { - return value >= 'A' && value <= 'Z' ? static_cast(value + ('a' - 'A')) : - value; - }; - return lower(lhs) == lower(rhs); - }); - }; - return endsWith(".dll"sv) || endsWith(".so"sv) || endsWith(".dylib"sv); -} - -NativeLocateResult locate_native_runtime(ModBundle& bundle) { - NativeRuntimeLocation result; - const std::string platformPrefix = fmt::format("{}{}/", k_nativeLibDir, k_nativePlatform); - const std::string nativeEntry = platformPrefix + std::string{k_nativeLibName}; - for (const auto& name : bundle.getFileNames()) { - if (name.find('/') == std::string::npos && has_native_library_extension(name)) { - return NativeLocateFailure{ - NativeModStatus::InvalidBundle, - fmt::format( - "native library '{}' found at the root (natives go in /lib/{{platform}})", - name), - }; - } - if (!name.starts_with(k_nativeLibDir)) { - continue; - } - - const std::string_view libPath{ - name.data() + k_nativeLibDir.size(), name.size() - k_nativeLibDir.size()}; - const auto platformEnd = libPath.find('/'); - if (platformEnd != std::string_view::npos) { - const auto entryName = libPath.substr(platformEnd + 1); - if (entryName.find('/') == std::string_view::npos && - (entryName == "mod.dll"sv || entryName == "mod.so"sv)) - { - result.anyLibs = true; - } - } - - if (!k_nativePlatform.empty() && name.starts_with(platformPrefix)) { - const std::string_view relativeName{ - name.data() + platformPrefix.size(), name.size() - platformPrefix.size()}; - if (!is_safe_resource_path(relativeName)) { - continue; - } - result.runtimeEntries.push_back(name); - } - if (name == nativeEntry) { - result.entry = name; - } - } - std::ranges::sort(result.runtimeEntries); - result.runtimeEntries.erase( - std::unique(result.runtimeEntries.begin(), result.runtimeEntries.end()), - result.runtimeEntries.end()); - return result; -} void complete_operation(const std::shared_ptr& operation, const bool success = true, std::string message = {}) { @@ -231,356 +75,6 @@ ModLoader& ModLoader::instance() { return g_modLoader; } -class InvalidModDataException : public std::runtime_error { -public: - explicit InvalidModDataException(const std::string& msg) : runtime_error(msg) {} - explicit InvalidModDataException(const char* msg) : runtime_error(msg) {} -}; - -static void validate_mod_id(std::string_view const str) { - if (str.empty()) { - throw InvalidModDataException("Missing ID value in mod metadata!"); - } - - bool lastWasPeriod = false; - for (auto const chr : str) { - if (chr == '.') { - if (lastWasPeriod) { - throw InvalidModDataException("Cannot have two consecutive periods in mod ID!"); - } - lastWasPeriod = true; - continue; - } - - lastWasPeriod = false; - - if (chr == '_') - continue; - - if (chr >= '0' && chr <= '9') - continue; - - if (chr >= 'a' && chr <= 'z') - continue; - - if (chr >= 'A' && chr <= 'Z') - continue; - - throw InvalidModDataException( - fmt::format("Invalid character '{}' in mod ID. Valid characters are period, " - "underscore, and alphanumerics.", - chr)); - } -} - -static bool bundle_has_file(ModBundle& bundle, const std::string& path) { - try { - bundle.getFileSize(path); - return true; - } catch (const std::runtime_error&) { - return false; - } -} - -static std::string resolve_image_path(ModBundle& bundle, const std::string& modId, - std::string_view key, const std::string& manifestPath, const std::string& defaultPath) { - if (!manifestPath.empty()) { - if (!is_safe_resource_path(manifestPath)) { - log::write( - modId, LOG_LEVEL_WARN, "invalid {} path '{}' in mod.json", key, manifestPath); - } else if (!bundle_has_file(bundle, manifestPath)) { - log::write( - modId, LOG_LEVEL_WARN, "{} path '{}' not found in bundle", key, manifestPath); - } else { - return manifestPath; - } - } - if (bundle_has_file(bundle, defaultPath)) { - return defaultPath; - } - return {}; -} - -struct LoadedManifest { - ModMetadata metadata; - std::optional runtime; -}; - -static uint16_t parse_runtime_version_component(std::string_view text, std::string_view fieldName) { - uint32_t value = 0; - const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value); - if (text.empty() || error != std::errc{} || end != text.data() + text.size() || - value > UINT16_MAX) - { - throw InvalidModDataException(fmt::format("Invalid {} in runtime version pin", fieldName)); - } - return static_cast(value); -} - -static std::optional parse_runtime(const nlohmann::json& manifest) { - const auto field = manifest.find("runtime"); - if (field == manifest.end()) { - return std::nullopt; - } - if (!field->is_string()) { - throw InvalidModDataException("runtime must be a string"); - } - - const std::string pin = field->get(); - const auto at = pin.rfind('@'); - if (at == std::string::npos || at == 0 || at + 1 == pin.size() || pin.find('@') != at || - at >= MOD_META_SERVICE_ID_SIZE) - { - throw InvalidModDataException( - "runtime must be a service id followed by @major or @major.minor"); - } - - const std::string_view version{pin.data() + at + 1, pin.size() - at - 1}; - const auto dot = version.find('.'); - if (dot != std::string_view::npos && version.find('.', dot + 1) != std::string_view::npos) { - throw InvalidModDataException("runtime version pin has too many components"); - } - - DelegatedModRuntime result; - result.id = pin.substr(0, at); - result.major = parse_runtime_version_component( - dot == std::string_view::npos ? version : version.substr(0, dot), "major version"); - if (dot != std::string_view::npos) { - result.minMinor = parse_runtime_version_component(version.substr(dot + 1), "minor version"); - } - return result; -} - -static LoadedManifest load_manifest(const std::filesystem::path& modPath, ModBundle& bundle) { - const auto metaJson = bundle.readFile("mod.json"); - auto j = nlohmann::json::parse(metaJson); - - std::string metaId = j.value("id", ""); - std::string metaName = j.value("name", ""); - std::string metaVersion = j.value("version", ""); - std::string metaAuthor = j.value("author", ""); - std::string metaDescription = j.value("description", ""); - std::string metaIcon = j.value("icon", ""); - std::string metaBanner = j.value("banner", ""); - - validate_mod_id(metaId); - - if (metaName.empty()) { - metaName = borealis::io::fs_path_to_string(modPath.stem()); - } - if (metaVersion.empty()) { - metaVersion = "?"s; - } - if (metaAuthor.empty()) { - metaAuthor = "unknown"s; - } - - std::string iconPath = resolve_image_path(bundle, metaId, "icon", metaIcon, "res/icon.png"s); - std::string bannerPath = - resolve_image_path(bundle, metaId, "banner", metaBanner, "res/banner.png"s); - - return LoadedManifest{ - .metadata = - { - std::move(metaId), - std::move(metaName), - std::move(metaVersion), - std::move(metaAuthor), - std::move(metaDescription), - std::move(iconPath), - std::move(bannerPath), - }, - .runtime = parse_runtime(j), - }; -} - -bool inspect_mod_bundle( - const fs::path& path, ModMetadata& metadata, std::string& error, bool* hasNative) noexcept { - try { - auto bundle = load_bundle(path, false); - metadata = load_manifest(path, *bundle).metadata; - if (hasNative != nullptr) { - *hasNative = std::ranges::any_of(bundle->getFileNames(), - [](const auto& name) { return has_native_library_extension(name); }); - } - error.clear(); - return true; - } catch (const std::exception& exception) { - error = exception.what(); - } catch (...) { - error = "Unknown bundle validation error"; - } - return false; -} - -// True if the first `capacity` bytes of `str` contain a NUL. -static bool terminated_within(const char* str, size_t capacity) { - return std::memchr(str, '\0', capacity) != nullptr; -} - -static bool parse_meta(NativeMod& native, LoadedMod& mod) { - const ModMeta* meta = native.meta; - if (meta->struct_size < sizeof(ModMeta)) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "mod_meta descriptor has invalid size {}", - meta->struct_size); - mod.nativeStatus = NativeModStatus::InvalidMetadata; - return false; - } - const auto* cursor = static_cast(meta->records_begin); - const auto* end = static_cast(meta->records_end); - if (cursor == nullptr || end == nullptr || cursor > end || - (reinterpret_cast(cursor) & 7) != 0) - { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "mod_meta section bounds are invalid"); - mod.nativeStatus = NativeModStatus::InvalidMetadata; - return false; - } - - ModMetaParsed parsed; - size_t headerCount = 0; - const auto invalid = [&](std::string_view why) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "invalid metadata record at offset {}: {}", - cursor - static_cast(meta->records_begin), why); - mod.nativeStatus = NativeModStatus::InvalidMetadata; - return false; - }; - - while (cursor < end) { - if (end - cursor < 8) { - return invalid("trailing bytes"); - } - uint64_t first = 0; - std::memcpy(&first, cursor, sizeof(first)); - if (first == 0) { // linker padding / bounds sentinel - cursor += 8; - continue; - } - - const auto* rec = reinterpret_cast(cursor); - const size_t size = rec->size; - if (size < 8 || size % 8 != 0 || size > static_cast(end - cursor)) { - return invalid("bad record size"); - } - - switch (rec->kind) { - case MOD_META_PAD: - break; - case MOD_META_HEADER: { - if (size < sizeof(ModMetaHeader)) { - return invalid("truncated header record"); - } - const auto* header = reinterpret_cast(rec); - ++headerCount; - parsed.abiVersion = header->abi_version; - break; - } - case MOD_META_IMPORT: { - if (size < sizeof(ModMetaImport)) { - return invalid("truncated import record"); - } - auto* record = reinterpret_cast(const_cast(cursor)); - if (!terminated_within(record->service_id.chars, sizeof(record->service_id.chars))) { - return invalid("unterminated import service id"); - } - parsed.imports.push_back(record); - break; - } - case MOD_META_EXPORT: { - if (size < sizeof(ModMetaExport)) { - return invalid("truncated export record"); - } - auto* record = reinterpret_cast(const_cast(cursor)); - if (!terminated_within(record->service_id.chars, sizeof(record->service_id.chars))) { - return invalid("unterminated export service id"); - } - parsed.exports.push_back(record); - break; - } - case MOD_META_HOOK_FN: { - if (size < sizeof(ModMetaHookFn)) { - return invalid("truncated hook record"); - } - parsed.hookFns.push_back( - reinterpret_cast(const_cast(cursor))); - break; - } - case MOD_META_HOOK_MEM: { - if (size <= sizeof(ModMetaHookMem)) { - return invalid("truncated hook record"); - } - auto* record = reinterpret_cast(const_cast(cursor)); - const char* strings = reinterpret_cast(cursor) + sizeof(ModMetaHookMem); - const size_t capacity = size - sizeof(ModMetaHookMem); - if (!terminated_within(strings, capacity)) { - return invalid("unterminated hook vtable symbol"); - } - const size_t vtableLen = std::char_traits::length(strings); - if (!terminated_within(strings + vtableLen + 1, capacity - vtableLen - 1)) { - return invalid("unterminated hook display name"); - } - parsed.hookMems.push_back(record); - break; - } - case MOD_META_HOOK_MEM_EXT: { - if (size <= sizeof(ModMetaHookMemExt)) { - return invalid("truncated extended hook record"); - } - auto* record = reinterpret_cast(const_cast(cursor)); - if (record->pmf_size <= MOD_META_HOOK_MEM_CAPACITY || - record->pmf_size > MOD_META_HOOK_MEM_EXT_CAPACITY || record->materialize == nullptr) - { - return invalid("bad extended hook member-pointer size"); - } - const char* strings = reinterpret_cast(cursor) + sizeof(ModMetaHookMemExt); - const size_t capacity = size - sizeof(ModMetaHookMemExt); - if (!terminated_within(strings, capacity)) { - return invalid("unterminated extended hook vtable symbol"); - } - const size_t vtableLen = std::char_traits::length(strings); - if (!terminated_within(strings + vtableLen + 1, capacity - vtableLen - 1)) { - return invalid("unterminated extended hook display name"); - } - parsed.hookMemExts.push_back(record); - break; - } - case MOD_META_HOOK_NAME: { - if (size <= sizeof(ModMetaHookName)) { - return invalid("truncated hook record"); - } - auto* record = reinterpret_cast(const_cast(cursor)); - const char* name = reinterpret_cast(cursor) + sizeof(ModMetaHookName); - if (!terminated_within(name, size - sizeof(ModMetaHookName))) { - return invalid("unterminated hook symbol name"); - } - parsed.hookNames.push_back(record); - break; - } - default: - // Additive record kinds may appear within a format version; skip them. - log::write(mod.metadata.id, LOG_LEVEL_DEBUG, "skipping unknown metadata record kind {}", - rec->kind); - break; - } - cursor += size; - } - - if (headerCount != 1) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "expected 1 metadata header record, found {}", - headerCount); - mod.nativeStatus = NativeModStatus::InvalidMetadata; - return false; - } - if (parsed.abiVersion != MOD_ABI_VERSION) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "expects ABI v{} but engine is v{}, skipping", - parsed.abiVersion, MOD_ABI_VERSION); - mod.nativeStatus = NativeModStatus::ApiVersionMismatch; - return false; - } - - native.parsed = std::move(parsed); - return true; -} - static std::string lifecycle_error_message( const char* fnName, const ModResult result, const ModError& error) { if (error.message[0] != '\0') { @@ -589,274 +83,6 @@ static std::string lifecycle_error_message( return fmt::format("{} failed with result {}", fnName, static_cast(result)); } -static std::string native_status_message(const NativeModStatus status) { - switch (status) { - case NativeModStatus::BuildDisabled: - return "Code mods are disabled on this Dusklight build"; - case NativeModStatus::ModMissingPlatform: - return fmt::format("Mod not supported on this platform ({})", k_nativePlatform); - case NativeModStatus::ApiVersionMismatch: - // TODO: differentiate whether mod or Dusklight is out of date - return "Mod ABI version mismatch"; - case NativeModStatus::MissingExport: - return "Missing required mod API exports"; - case NativeModStatus::InvalidMetadata: - return "Invalid mod metadata records"; - case NativeModStatus::InvalidBundle: - return "Invalid mod bundle layout (old mod?)"; - case NativeModStatus::Unknown: - return "Unknown mod load failure"; - case NativeModStatus::None: - case NativeModStatus::Loaded: - break; - } - return "native mod failed to load"; -} - -fs::path ModLoader::external_native_lib_path(const LoadedMod& mod) const { - if (k_nativeLibName.empty()) { - return {}; - } - const auto& libDir = m_searchDirs[mod.searchDirIndex].nativeLibDir; - if (libDir.empty()) { - return {}; - } - const auto filename = fmt::format("{}{}", mod.metadata.id, - borealis::io::fs_path_to_string(fs::path{k_nativeLibName}.extension())); - fs::path path = libDir / fs::path{filename}; - std::error_code ec; - if (!fs::is_regular_file(path, ec)) { - return {}; - } - return path; -} - -void ModLoader::load_native( - LoadedMod& mod, const std::string& dllEntry, const std::vector& runtimeEntries) { - if (!EnableCodeMods) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "Code mods are not available in this build"); - mod.nativeStatus = NativeModStatus::BuildDisabled; - return; - } - - const fs::path cacheDir = m_cacheDir / mod.metadata.id; - const fs::path scratchDir = cacheDir / "data"; - std::error_code ec; - fs::create_directories(scratchDir, ec); - if (ec) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to create mod directory {}: {}", - data::abbreviated_path_string(scratchDir), ec.message()); - return; - } - mod.dir = fs::absolute(scratchDir); - mod.dirUtf8 = borealis::io::fs_path_to_string(mod.dir); - - fs::path libPath; - fs::path runtimeDir; - DirectoryRollback runtimeDirRollback; - if (mod.nativeInPlace) { - if (!dllEntry.empty()) { - libPath = mod.modPath / dllEntry; - } else if (auto external = external_native_lib_path(mod); !external.empty()) { - libPath = std::move(external); - } else { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, - "no native library named {} found; skipping", k_nativeLibName); - mod.nativeStatus = NativeModStatus::ModMissingPlatform; - return; - } - runtimeDir = libPath.parent_path(); - } else { - if (dllEntry.empty()) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, - "no native library named {} found; skipping", k_nativeLibName); - mod.nativeStatus = NativeModStatus::ModMissingPlatform; - return; - } - - // Every generation gets a new directory. The main module and all of its runtime - // libraries therefore have fresh paths and can coexist with a previous generation - // that is still unwinding after a reload. - runtimeDir = cacheDir / fmt::format("g{}", ++mod.cacheGeneration); - runtimeDirRollback.set_path(runtimeDir); - fs::create_directories(runtimeDir, ec); - if (ec) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, - "failed to create native runtime directory {}: {}", - data::abbreviated_path_string(runtimeDir), ec.message()); - return; - } - - const std::string platformPrefix = fmt::format("{}{}/", k_nativeLibDir, k_nativePlatform); - for (const auto& entry : runtimeEntries) { - if (!entry.starts_with(platformPrefix)) { - continue; - } - const std::string_view relativeName{ - entry.data() + platformPrefix.size(), entry.size() - platformPrefix.size()}; - if (!is_safe_resource_path(relativeName)) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, - "unsafe native runtime path '{}'; skipping", entry); - return; - } - - const fs::path outputPath = runtimeDir / fs::path{relativeName}; - fs::create_directories(outputPath.parent_path(), ec); - if (ec) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, - "failed to create directory for {}: {}", entry, ec.message()); - return; - } - - std::vector data; - try { - data = mod.bundle->readFile(entry); - } catch (const std::exception& e) { - log::write( - mod.metadata.id, LOG_LEVEL_ERROR, "failed to extract {}: {}", entry, e.what()); - return; - } - - std::ofstream out(outputPath, std::ios::binary | std::ios::out); - if (!out) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to write {}", entry); - return; - } - out.write(reinterpret_cast(data.data()), - static_cast(data.size())); - if (!out) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to write {}", entry); - return; - } - } - - libPath = runtimeDir / fs::path{dllEntry}.filename(); - } - - auto nativeMod = std::make_unique(); - try { - nativeMod->handle = std::make_unique(libPath); - } catch (const std::runtime_error& e) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to open {}: {}", - data::abbreviated_path_string(libPath), e.what()); - return; - } - - nativeMod->meta = nativeMod->handle->LookupSymbol("mod_meta"); - nativeMod->contextSymbol = nativeMod->handle->LookupSymbol("mod_ctx"); - nativeMod->fn_initialize = nativeMod->handle->LookupSymbol("mod_initialize"); - nativeMod->fn_update = nativeMod->handle->LookupSymbol("mod_update"); - nativeMod->fn_shutdown = nativeMod->handle->LookupSymbol("mod_shutdown"); - - if (!nativeMod->meta || !nativeMod->contextSymbol || !nativeMod->fn_initialize || - !nativeMod->fn_update || !nativeMod->fn_shutdown) - { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, - "{} missing required mod API exports; skipping", - data::abbreviated_path_string(libPath)); - mod.nativeStatus = NativeModStatus::MissingExport; - return; - } - - if (!parse_meta(*nativeMod, mod)) { - return; - } - - if (nativeMod->contextSymbol == nullptr) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "missing required mod_ctx export"); - mod.nativeStatus = NativeModStatus::MissingExport; - return; - } - *nativeMod->contextSymbol = mod.context.get(); - - mod.nativePath = fs::absolute(libPath); - mod.nativeDir = fs::absolute(runtimeDir); - mod.nativeDirUtf8 = borealis::io::fs_path_to_string(mod.nativeDir); - mod.native = std::move(nativeMod); - mod.nativeStatus = NativeModStatus::Loaded; - runtimeDirRollback.release(); -} - -bool ModLoader::load_native_if_present(LoadedMod& mod) { - const auto result = locate_native_runtime(*mod.bundle); - if (const auto* failure = std::get_if(&result)) { - mod.nativeStatus = failure->status; - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "{}", failure->logMessage); - fail_mod(mod, MOD_ERROR, native_status_message(failure->status)); - return false; - } - - const auto& native = std::get(result); - if (mod.runtime.has_value() && - (native.anyLibs || (mod.nativeInPlace && !external_native_lib_path(mod).empty()))) - { - mod.nativeStatus = NativeModStatus::InvalidBundle; - fail_mod(mod, MOD_CONFLICT, "A mod cannot declare both runtime and native code"); - return false; - } - if (!native.anyLibs && !(mod.nativeInPlace && !external_native_lib_path(mod).empty())) { - mod.nativeStatus = NativeModStatus::None; - return true; - } - - mod.nativeStatus = NativeModStatus::Unknown; - load_native(mod, native.entry, native.runtimeEntries); - if (mod.nativeStatus != NativeModStatus::Loaded) { - fail_mod(mod, MOD_ERROR, native_status_message(mod.nativeStatus)); - return false; - } - return true; -} - -void ModLoader::unload_native(LoadedMod& mod) { - if (!mod.native || mod.nativeInPlace) { - return; - } - // Deferred dlclose: this mod's code may still be on the stack below the current tick - m_retiredNatives.push_back({std::move(mod.native), std::move(mod.nativeDir)}); - mod.nativePath.clear(); - mod.nativeDir.clear(); - mod.nativeDirUtf8.clear(); -} - -void ModLoader::drain_retired_natives() { - for (auto& retired : m_retiredNatives) { - retired.native.reset(); - if (!retired.directory.empty()) { - std::error_code ec; - fs::remove_all(retired.directory, ec); - } - } - m_retiredNatives.clear(); -} - -static ModManifestInfo build_manifest_info(const ModMetaParsed& parsed) { - ModManifestInfo info; - info.imports.reserve(parsed.imports.size()); - for (const auto* record : parsed.imports) { - if (!svc::valid_service_id(record->service_id.chars)) { - continue; - } - info.imports.push_back({ - .id = record->service_id.chars, - .major = record->major_version, - .minMinor = record->min_minor_version, - .required = (record->rec.flags & SERVICE_IMPORT_OPTIONAL) == 0, - }); - } - info.exports.reserve(parsed.exports.size()); - for (const auto* record : parsed.exports) { - if (!svc::valid_service_id(record->service_id.chars)) { - continue; - } - info.exports.push_back({ - .id = record->service_id.chars, - .major = record->major_version, - }); - } - return info; -} - std::string escape_mod_id_for_config(std::string_view const id) { std::string buf; @@ -944,6 +170,7 @@ LoadedMod* ModLoader::try_load_mod(const fs::path& modPath, bool fromDir, uint32 mod.active = true; mod.modPath = fs::absolute(modPath); mod.searchDirIndex = searchDirIndex; + mod.fromDirectory = fromDir; mod.nativeInPlace = m_searchDirs[searchDirIndex].inPlaceNative && fromDir; mod.fileIdentity = file_identity(modPath); mod.metadata = std::move(manifest.metadata); @@ -1134,69 +361,17 @@ void ModLoader::init() { // Stale libs from previous sessions (see load_native). fs::remove_all(m_cacheDir, ec); - // A Windows update can be interrupted between moving the live archive aside and publishing - // its replacement. Recover that narrow crash window before scanning the user directory. - if (fs::is_directory(m_searchDirs.front().path, ec)) { - for (const auto& entry : fs::directory_iterator(m_searchDirs.front().path, ec)) { - const auto path = entry.path(); - if (!entry.is_regular_file() || path.extension() != ".old" || - path.stem().extension() != ".dusk") - { - continue; - } - auto primary = path; - primary.replace_extension(); - if (fs::exists(primary, ec)) { - fs::remove(path, ec); - } else { - fs::rename(path, primary, ec); - } - if (ec) { - Log.warn("failed to recover stale mod archive '{}': {}", - data::abbreviated_path_string(path), ec.message()); - ec.clear(); - } - } - } - - for (size_t dirIndex = 0; dirIndex < m_searchDirs.size(); ++dirIndex) { - const auto& searchDir = m_searchDirs[dirIndex]; - - // --mods can point the user dir at the bundled dir; don't scan the same dir twice. - bool alreadyScanned = false; - for (size_t earlier = 0; earlier < dirIndex && !alreadyScanned; ++earlier) { - alreadyScanned = fs::equivalent(m_searchDirs[earlier].path, searchDir.path, ec); - } - if (alreadyScanned) { + const auto packages = scan_packages(m_searchDirs); + for (const auto& package : packages) { + const auto* selected = select_package(packages, package.metadata.id); + if (selected != &package) { + log::write(package.metadata.id, LOG_LEVEL_INFO, "{} v{} shadowed by {} v{}", + data::abbreviated_path_string(package.path), package.metadata.version, + data::abbreviated_path_string(selected->path), selected->metadata.version); continue; } - - if (!fs::is_directory(searchDir.path)) { - if (dirIndex == 0) { - Log.info( - "mods directory '{}' not found", data::abbreviated_path_string(searchDir.path)); - } else { - Log.debug( - "mods directory '{}' not found", data::abbreviated_path_string(searchDir.path)); - } - continue; - } - - std::vector entries; - for (auto& e : fs::directory_iterator(searchDir.path, ec)) { - if (e.is_directory() && fs::exists(e.path() / "mod.json")) { - entries.push_back(e); - } else if (e.is_regular_file() && e.path().extension() == ".dusk") { - entries.push_back(e); - } - } - std::sort(entries.begin(), entries.end(), - [](const fs::directory_entry& a, const fs::directory_entry& b) { - return a.path().filename() < b.path().filename(); - }); - - for (auto& entry : entries) { - (void)try_load_mod(entry.path(), entry.is_directory(), static_cast(dirIndex)); + if (auto* mod = try_load_mod(package.path, package.fromDirectory, package.searchDirIndex)) { + record_package_sources(*mod, packages); } } @@ -1349,7 +524,11 @@ fs::path ModLoader::user_mods_dir() const { } bool ModLoader::can_uninstall(const LoadedMod& mod) const { - return mod.searchDirIndex == 0 && mod.modPath.extension() == ".dusk"; + return mod.hasUserPackage; +} + +bool ModLoader::can_update(const LoadedMod& mod) const { + return mod.searchDirIndex != 0 || (!mod.fromDirectory && can_uninstall(mod)); } void ModLoader::notify_mod_failure(LoadedMod& mod, bool firstFailure) { @@ -1360,7 +539,7 @@ void ModLoader::notify_mod_failure(LoadedMod& mod, bool firstFailure) { if (!m_startupComplete) { return; } - m_pendingRequests.push_back(LifecycleRequest{ + m_pendingRequests.emplace_back(LifecycleRequest{ .modId = mod.metadata.id, .action = LifecycleAction::Disable, }); @@ -1425,13 +604,6 @@ std::vector ModLoader::collect_lifecycle_set(LoadedMod& target) cons return ordered; } -bool ModLoader::ensure_native_loaded(LoadedMod& mod) { - if (mod.native || mod.nativeStatus == NativeModStatus::None) { - return true; - } - return load_native_if_present(mod); -} - bool ModLoader::reload_bundle(LoadedMod& mod) { log::write(mod.metadata.id, LOG_LEVEL_INFO, "reloading from {}", data::abbreviated_path_string(mod.modPath)); @@ -1489,8 +661,6 @@ bool ModLoader::reload_bundle(LoadedMod& mod) { } void ModLoader::resume_lifecycle_set(const std::vector& affected) { - // Publish every candidate's static exports before any initialize, so optional cycles and - // provider changes use the same ordering rules as startup. for (auto* mod : affected) { if (mod->active || mod->loadFailed || !mod->cvarIsEnabled->getValue()) { continue; @@ -1530,7 +700,8 @@ void ModLoader::resume_lifecycle_set(const std::vector& affected) { } } -void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) { +void ModLoader::apply_lifecycle_change( + LoadedMod& target, const bool reload, const PackageCandidate* replacement) { auto affected = collect_lifecycle_set(target); // Dependents first (reverse init order), like shutdown. @@ -1550,6 +721,17 @@ void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) { } } + if (replacement != nullptr) { + target.modPath = replacement->path; + target.searchDirIndex = replacement->searchDirIndex; + target.fromDirectory = replacement->fromDirectory; + target.nativeInPlace = + replacement->fromDirectory && m_searchDirs[replacement->searchDirIndex].inPlaceNative; + std::stable_sort(m_mods.begin(), m_mods.end(), + [](const auto& a, const auto& b) { return a->searchDirIndex > b->searchDirIndex; }); + loader::sort_mods(m_mods); + } + if (reload) { // On failure the target is failed and stays down; dependents get resume attempts below // and suspend against the failed provider where required. @@ -1581,7 +763,6 @@ void ModLoader::on_enabled_changed(LoadedMod& mod) { } if (mod.suspendedByProvider) { if (!mod.cvarIsEnabled->getValue()) { - // The user disabled a suspended mod; stop waiting for its providers. mod.suspendedByProvider = false; } return; @@ -1713,24 +894,72 @@ ModLoader::OperationResult ModLoader::load_runtime_mod(const fs::path& requested return runtime_result(*mod); } -ModLoader::OperationResult ModLoader::reload_runtime_mod(LoadedMod& mod) { - if (mod.nativeInPlace) { +ModLoader::OperationResult ModLoader::reload_runtime_mod( + LoadedMod& mod, const PackageCandidate* replacement) { + if (mod.nativeInPlace && replacement == nullptr) { return { .success = false, - .message = "Built-in mods cannot be updated in-game", + .message = "An in-place native library cannot be reloaded", .mod = &mod, }; } - apply_lifecycle_change(mod, true); + apply_lifecycle_change(mod, true, replacement); ++m_generation; return runtime_result(mod); } +ModLoader::OperationResult ModLoader::uninstall_runtime_mod(LoadedMod& mod) { + std::vector packages; + try { + packages = scan_packages(m_searchDirs); + } catch (const std::exception& exception) { + return {.success = false, .message = exception.what()}; + } + record_package_sources(mod, packages); + if (!can_uninstall(mod)) { + return {.success = false, .message = "No installed package to remove"}; + } + + std::string removalError; + std::erase_if(packages, [&](const auto& package) { + if (package.metadata.id != mod.metadata.id || package.searchDirIndex != 0 || + package.fromDirectory || package.symlink) + { + return false; + } + std::error_code error; + fs::remove(package.path, error); + if (error) { + removalError = fmt::format("Could not remove {}: {}", + data::abbreviated_path_string(package.path), error.message()); + } + return !error; + }); + + OperationResult result; + if (const auto* selected = select_package(packages, mod.metadata.id)) { + if (selected->path != mod.modPath) { + result = reload_runtime_mod(mod, selected); + } else { + result.mod = &mod; + ++m_generation; + } + record_package_sources(mod, packages); + } else { + forget_mod(mod); + } + if (!removalError.empty()) { + result.success = false; + result.message = std::move(removalError); + } + return result; +} + ModLoader::OperationResult ModLoader::runtime_result(LoadedMod& mod) { if (mod.loadFailed) { return { .success = false, - .message = mod.failureReason.empty() ? "The mod failed to activate" : mod.failureReason, + .message = mod.failureReason.empty() ? "Mod failed to activate" : mod.failureReason, .mod = &mod, }; } @@ -1788,62 +1017,71 @@ ModLoader::OperationResult ModLoader::install_staged(const fs::path& requestedPa }; } - fs::path destination; + const auto destination = userDir / fmt::format("{}.dusk", safe_filename(metadata.id)); auto* installed = find_mod(metadata.id); - if (installed != nullptr) { - if (!can_uninstall(*installed)) { - return { - .success = false, - .message = "This bundled mod cannot be updated in-game", - }; - } - destination = installed->modPath; - } else { - destination = userDir / fmt::format("{}.dusk", safe_filename(metadata.id)); + if (installed != nullptr && !can_update(*installed)) { + return { + .success = false, + .message = "Cannot install mod over a development directory", + }; } - std::string replaceError; -#ifdef _WIN32 - fs::path aside = destination; - aside += ".old"; - const bool hadDestination = fs::exists(destination, error); - if (hadDestination) { - fs::remove(aside, error); - error.clear(); - fs::rename(destination, aside, error); - if (error) { + for (const auto& mod : mods()) { + if (mod.metadata.id != metadata.id && fs::equivalent(mod.modPath, destination, error)) { return { .success = false, - .message = - fmt::format("Failed to prepare the installed package: {}", error.message()), + .message = "The destination filename belongs to a different mod", }; } } - if (!borealis::io::atomic_replace(path, destination, replaceError)) { - if (hadDestination) { - std::error_code restoreError; - fs::rename(aside, destination, restoreError); - } + error.clear(); + + if (!borealis::update::parse_version(metadata.version)) { + return {.success = false, .message = "The package version is invalid"}; + } + std::vector packages; + try { + packages = scan_packages(m_searchDirs); + } catch (const std::exception& exception) { + return {.success = false, .message = exception.what()}; + } + if (const auto* selected = select_package(packages, metadata.id); + selected && compare_package_versions(metadata.version, selected->metadata.version) < 0) + { return { .success = false, - .message = std::move(replaceError), + .message = fmt::format( + "A newer version ({}) is already installed", selected->metadata.version), }; } - auto result = - installed != nullptr ? reload_runtime_mod(*installed) : load_runtime_mod(destination); - if (hadDestination) { - fs::remove(aside, error); + + const auto packageResult = install_package(path, destination, metadata.id); + if (!packageResult.replaced) { + return {.success = false, .message = packageResult.error}; + } + std::erase_if(packages, [&](const auto& package) { + if (package.metadata.id != metadata.id || package.searchDirIndex != 0 || + package.fromDirectory) + { + return false; + } + std::error_code statusError; + return fs::equivalent(package.path, destination, statusError) || + !fs::exists(package.path, statusError); + }); + packages.push_back({.path = destination, .metadata = metadata}); + const auto* selected = select_package(packages, metadata.id); + auto result = installed != nullptr ? reload_runtime_mod(*installed, selected) : + load_runtime_mod(selected->path); + if (result.mod != nullptr) { + record_package_sources(*result.mod, packages); + } + + if (result.success && !packageResult.error.empty()) { + result.success = false; + result.message = packageResult.error; } return result; -#else - if (!borealis::io::atomic_replace(path, destination, replaceError)) { - return { - .success = false, - .message = std::move(replaceError), - }; - } - return installed != nullptr ? reload_runtime_mod(*installed) : load_runtime_mod(destination); -#endif } void ModLoader::apply_pending_requests() { @@ -1854,7 +1092,6 @@ void ModLoader::apply_pending_requests() { return; } - // Package mutations retain request order. Enable/disable/reactivate can still coalesce per mod. const auto requests = std::exchange(m_pendingRequests, {}); std::vector coalesced; for (const auto& request : requests) { @@ -1915,28 +1152,18 @@ void ModLoader::apply_pending_requests() { complete_operation(uninstall->operation); continue; } - if (!can_uninstall(*mod)) { - complete_operation( - uninstall->operation, false, "The mod is part of this Dusklight installation"); - continue; - } - const auto removedName = mod->metadata.name; const auto removedId = mod->metadata.id; - std::error_code error; - if (!fs::remove(mod->modPath, error)) { - complete_operation(uninstall->operation, false, - error ? error.message() : "The package was not found"); - continue; + auto result = uninstall_runtime_mod(*mod); + if (result.success) { + queue::remove_by_mod_id(removedId); + ui::push_toast({ + .title = result.mod != nullptr ? "User update removed" : "Mod uninstalled", + .content = removedName, + .duration = std::chrono::seconds{2}, + }); } - forget_mod(*mod); - queue::remove_by_mod_id(removedId); - complete_operation(uninstall->operation); - ui::push_toast({ - .title = "Mod uninstalled", - .content = removedName, - .duration = std::chrono::seconds{2}, - }); + complete_operation(uninstall->operation, result.success, std::move(result.message)); continue; } diff --git a/src/dusk/mods/loader/manifest.cpp b/src/dusk/mods/loader/manifest.cpp new file mode 100644 index 0000000000..8c8243bd0a --- /dev/null +++ b/src/dusk/mods/loader/manifest.cpp @@ -0,0 +1,203 @@ +#include "manifest.hpp" + +#include "loader.hpp" +#include "natives.hpp" +#include "packages.hpp" + +#include "dusk/mods/log_buffer.hpp" + +#include +#include +#include + +#include +#include +#include +#include + +using namespace std::string_literals; +namespace fs = std::filesystem; + +namespace dusk::mods { +namespace { + +class InvalidModDataException : public std::runtime_error { +public: + explicit InvalidModDataException(const std::string& msg) : runtime_error(msg) {} + explicit InvalidModDataException(const char* msg) : runtime_error(msg) {} +}; + +void validate_mod_id(std::string_view const str) { + if (str.empty()) { + throw InvalidModDataException("Missing ID value in mod metadata"); + } + + bool lastWasPeriod = false; + for (auto const chr : str) { + if (chr == '.') { + if (lastWasPeriod) { + throw InvalidModDataException("Cannot have two consecutive periods in mod ID"); + } + lastWasPeriod = true; + continue; + } + + lastWasPeriod = false; + + if (chr == '_') + continue; + + if (chr >= '0' && chr <= '9') + continue; + + if (chr >= 'a' && chr <= 'z') + continue; + + if (chr >= 'A' && chr <= 'Z') + continue; + + throw InvalidModDataException( + fmt::format("Invalid character '{}' in mod ID. Valid characters are period, " + "underscore, and alphanumerics.", + chr)); + } +} + +bool bundle_has_file(ModBundle& bundle, const std::string& path) { + try { + bundle.getFileSize(path); + return true; + } catch (const std::runtime_error&) { + return false; + } +} + +std::string resolve_image_path(ModBundle& bundle, const std::string& modId, std::string_view key, + const std::string& manifestPath, const std::string& defaultPath) { + if (!manifestPath.empty()) { + if (!is_safe_resource_path(manifestPath)) { + log::write( + modId, LOG_LEVEL_WARN, "invalid {} path '{}' in mod.json", key, manifestPath); + } else if (!bundle_has_file(bundle, manifestPath)) { + log::write( + modId, LOG_LEVEL_WARN, "{} path '{}' not found in bundle", key, manifestPath); + } else { + return manifestPath; + } + } + if (bundle_has_file(bundle, defaultPath)) { + return defaultPath; + } + return {}; +} + +uint16_t parse_runtime_version_component(std::string_view text, std::string_view fieldName) { + uint32_t value = 0; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value); + if (text.empty() || error != std::errc{} || end != text.data() + text.size() || + value > UINT16_MAX) + { + throw InvalidModDataException(fmt::format("Invalid {} in runtime version pin", fieldName)); + } + return static_cast(value); +} + +std::optional parse_runtime(const nlohmann::json& manifest) { + const auto field = manifest.find("runtime"); + if (field == manifest.end()) { + return std::nullopt; + } + if (!field->is_string()) { + throw InvalidModDataException("runtime must be a string"); + } + + const std::string pin = field->get(); + const auto at = pin.rfind('@'); + if (at == std::string::npos || at == 0 || at + 1 == pin.size() || pin.find('@') != at || + at >= MOD_META_SERVICE_ID_SIZE) + { + throw InvalidModDataException( + "runtime must be a service id followed by @major or @major.minor"); + } + + const std::string_view version{pin.data() + at + 1, pin.size() - at - 1}; + const auto dot = version.find('.'); + if (dot != std::string_view::npos && version.find('.', dot + 1) != std::string_view::npos) { + throw InvalidModDataException("runtime version pin has too many components"); + } + + DelegatedModRuntime result; + result.id = pin.substr(0, at); + result.major = parse_runtime_version_component( + dot == std::string_view::npos ? version : version.substr(0, dot), "major version"); + if (dot != std::string_view::npos) { + result.minMinor = parse_runtime_version_component(version.substr(dot + 1), "minor version"); + } + return result; +} + +} // namespace + +LoadedManifest load_manifest(const std::filesystem::path& modPath, ModBundle& bundle) { + const auto metaJson = bundle.readFile("mod.json"); + auto j = nlohmann::json::parse(metaJson); + + std::string metaId = j.value("id", ""); + std::string metaName = j.value("name", ""); + std::string metaVersion = j.value("version", ""); + std::string metaAuthor = j.value("author", ""); + std::string metaDescription = j.value("description", ""); + std::string metaIcon = j.value("icon", ""); + std::string metaBanner = j.value("banner", ""); + + validate_mod_id(metaId); + + if (metaName.empty()) { + metaName = borealis::io::fs_path_to_string(modPath.stem()); + } + if (metaVersion.empty()) { + metaVersion = "?"s; + } + if (metaAuthor.empty()) { + metaAuthor = "unknown"s; + } + + std::string iconPath = resolve_image_path(bundle, metaId, "icon", metaIcon, "res/icon.png"s); + std::string bannerPath = + resolve_image_path(bundle, metaId, "banner", metaBanner, "res/banner.png"s); + + return LoadedManifest{ + .metadata = + { + std::move(metaId), + std::move(metaName), + std::move(metaVersion), + std::move(metaAuthor), + std::move(metaDescription), + std::move(iconPath), + std::move(bannerPath), + }, + .runtime = parse_runtime(j), + }; +} + +bool inspect_mod_bundle( + const fs::path& path, ModMetadata& metadata, std::string& error, bool* hasNative) noexcept { + try { + auto bundle = load_bundle(path, false); + metadata = load_manifest(path, *bundle).metadata; + if (hasNative != nullptr) { + *hasNative = std::ranges::any_of(bundle->getFileNames(), + [](const auto& name) { return has_native_library_extension(name); }); + } + error.clear(); + return true; + } catch (const std::exception& exception) { + error = exception.what(); + } catch (...) { + error = "Unknown bundle validation error"; + } + return false; +} + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/manifest.hpp b/src/dusk/mods/loader/manifest.hpp new file mode 100644 index 0000000000..e3eea70298 --- /dev/null +++ b/src/dusk/mods/loader/manifest.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include "dusk/mod_loader.hpp" + +namespace dusk::mods { + +struct LoadedManifest { + ModMetadata metadata; + std::optional runtime; +}; + +LoadedManifest load_manifest(const std::filesystem::path& modPath, ModBundle& bundle); + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/natives.cpp b/src/dusk/mods/loader/natives.cpp new file mode 100644 index 0000000000..e3802f4b52 --- /dev/null +++ b/src/dusk/mods/loader/natives.cpp @@ -0,0 +1,620 @@ +#include "natives.hpp" + +#include "loader.hpp" +#include "native_module.hpp" + +#include "dusk/data.hpp" +#include "dusk/mods/log_buffer.hpp" +#include "dusk/mods/svc/registry.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace std::string_view_literals; +namespace fs = std::filesystem; + +#if defined(_WIN32) +#if defined(_M_ARM64) +static constexpr std::string_view k_nativePlatform = "windows-arm64"sv; +#elif defined(_M_X64) +static constexpr std::string_view k_nativePlatform = "windows-amd64"sv; +#elif defined(_M_IX86) +static constexpr std::string_view k_nativePlatform = "windows-x86"sv; +#else +static constexpr std::string_view k_nativePlatform = ""sv; +#endif +static constexpr std::string_view k_nativeLibName = "mod.dll"sv; +#elif defined(__ANDROID__) +#if defined(__aarch64__) +static constexpr std::string_view k_nativePlatform = "android-aarch64"sv; +#elif defined(__x86_64__) +static constexpr std::string_view k_nativePlatform = "android-x86_64"sv; +#else +static constexpr std::string_view k_nativePlatform = ""sv; +#endif +static constexpr std::string_view k_nativeLibName = "mod.so"sv; +#elif defined(__APPLE__) +#include +#if TARGET_OS_IOS +static constexpr std::string_view k_nativePlatform = "ios-arm64"sv; +#elif TARGET_OS_TV +static constexpr std::string_view k_nativePlatform = "tvos-arm64"sv; +#elif defined(__aarch64__) +static constexpr std::string_view k_nativePlatform = "macos-arm64"sv; +#elif defined(__x86_64__) +static constexpr std::string_view k_nativePlatform = "macos-x86_64"sv; +#else +static constexpr std::string_view k_nativePlatform = ""sv; +#endif +static constexpr std::string_view k_nativeLibName = "mod.so"sv; +#elif defined(__linux__) +#if defined(__aarch64__) +static constexpr std::string_view k_nativePlatform = "linux-aarch64"sv; +#elif defined(__x86_64__) +static constexpr std::string_view k_nativePlatform = "linux-x86_64"sv; +#elif defined(__i386__) +static constexpr std::string_view k_nativePlatform = "linux-x86"sv; +#else +static constexpr std::string_view k_nativePlatform = ""sv; +#endif +static constexpr std::string_view k_nativeLibName = "mod.so"sv; +#else +static constexpr std::string_view k_nativePlatform = ""sv; +static constexpr std::string_view k_nativeLibName = ""sv; +#endif + +namespace dusk::mods { + +bool has_native_library_extension(std::string_view name) { + const auto endsWith = [name](std::string_view extension) { + if (name.size() < extension.size()) { + return false; + } + const auto suffix = name.substr(name.size() - extension.size()); + return std::ranges::equal(suffix, extension, [](char lhs, char rhs) { + const auto lower = [](char value) { + return value >= 'A' && value <= 'Z' ? static_cast(value + ('a' - 'A')) : + value; + }; + return lower(lhs) == lower(rhs); + }); + }; + return endsWith(".dll"sv) || endsWith(".so"sv) || endsWith(".dylib"sv); +} + +namespace { + +constexpr std::string_view k_nativeLibDir = "lib/"sv; + +class DirectoryRollback { +public: + ~DirectoryRollback() { + if (!mPath.empty()) { + std::error_code ec; + fs::remove_all(mPath, ec); + } + } + + void set_path(fs::path path) { mPath = std::move(path); } + void release() { mPath.clear(); } + +private: + fs::path mPath; +}; + +struct NativeRuntimeLocation { + std::string entry; + std::vector runtimeEntries; + bool anyLibs = false; +}; + +struct NativeLocateFailure { + NativeModStatus status; + std::string logMessage; +}; + +using NativeLocateResult = std::variant; + +NativeLocateResult locate_native_runtime(ModBundle& bundle) { + NativeRuntimeLocation result; + const std::string platformPrefix = fmt::format("{}{}/", k_nativeLibDir, k_nativePlatform); + const std::string nativeEntry = platformPrefix + std::string{k_nativeLibName}; + for (const auto& name : bundle.getFileNames()) { + if (name.find('/') == std::string::npos && has_native_library_extension(name)) { + return NativeLocateFailure{ + NativeModStatus::InvalidBundle, + fmt::format( + "native library '{}' found at the root (natives go in /lib/{{platform}})", + name), + }; + } + if (!name.starts_with(k_nativeLibDir)) { + continue; + } + + const std::string_view libPath{ + name.data() + k_nativeLibDir.size(), name.size() - k_nativeLibDir.size()}; + const auto platformEnd = libPath.find('/'); + if (platformEnd != std::string_view::npos) { + const auto entryName = libPath.substr(platformEnd + 1); + if (entryName.find('/') == std::string_view::npos && + (entryName == "mod.dll"sv || entryName == "mod.so"sv)) + { + result.anyLibs = true; + } + } + + if (!k_nativePlatform.empty() && name.starts_with(platformPrefix)) { + const std::string_view relativeName{ + name.data() + platformPrefix.size(), name.size() - platformPrefix.size()}; + if (!is_safe_resource_path(relativeName)) { + continue; + } + result.runtimeEntries.push_back(name); + } + if (name == nativeEntry) { + result.entry = name; + } + } + std::ranges::sort(result.runtimeEntries); + result.runtimeEntries.erase( + std::unique(result.runtimeEntries.begin(), result.runtimeEntries.end()), + result.runtimeEntries.end()); + return result; +} + +// True if the first `capacity` bytes of `str` contain a NUL. +bool terminated_within(const char* str, size_t capacity) { + return std::memchr(str, '\0', capacity) != nullptr; +} + +bool parse_meta(NativeMod& native, LoadedMod& mod) { + const ModMeta* meta = native.meta; + if (meta->struct_size < sizeof(ModMeta)) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "mod_meta descriptor has invalid size {}", + meta->struct_size); + mod.nativeStatus = NativeModStatus::InvalidMetadata; + return false; + } + const auto* cursor = static_cast(meta->records_begin); + const auto* end = static_cast(meta->records_end); + if (cursor == nullptr || end == nullptr || cursor > end || + (reinterpret_cast(cursor) & 7) != 0) + { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "mod_meta section bounds are invalid"); + mod.nativeStatus = NativeModStatus::InvalidMetadata; + return false; + } + + ModMetaParsed parsed; + size_t headerCount = 0; + const auto invalid = [&](std::string_view why) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "invalid metadata record at offset {}: {}", + cursor - static_cast(meta->records_begin), why); + mod.nativeStatus = NativeModStatus::InvalidMetadata; + return false; + }; + + while (cursor < end) { + if (end - cursor < 8) { + return invalid("trailing bytes"); + } + uint64_t first = 0; + std::memcpy(&first, cursor, sizeof(first)); + if (first == 0) { // linker padding / bounds sentinel + cursor += 8; + continue; + } + + const auto* rec = reinterpret_cast(cursor); + const size_t size = rec->size; + if (size < 8 || size % 8 != 0 || size > static_cast(end - cursor)) { + return invalid("bad record size"); + } + + switch (rec->kind) { + case MOD_META_PAD: + break; + case MOD_META_HEADER: { + if (size < sizeof(ModMetaHeader)) { + return invalid("truncated header record"); + } + const auto* header = reinterpret_cast(rec); + ++headerCount; + parsed.abiVersion = header->abi_version; + break; + } + case MOD_META_IMPORT: { + if (size < sizeof(ModMetaImport)) { + return invalid("truncated import record"); + } + auto* record = reinterpret_cast(const_cast(cursor)); + if (!terminated_within(record->service_id.chars, sizeof(record->service_id.chars))) { + return invalid("unterminated import service id"); + } + parsed.imports.push_back(record); + break; + } + case MOD_META_EXPORT: { + if (size < sizeof(ModMetaExport)) { + return invalid("truncated export record"); + } + auto* record = reinterpret_cast(const_cast(cursor)); + if (!terminated_within(record->service_id.chars, sizeof(record->service_id.chars))) { + return invalid("unterminated export service id"); + } + parsed.exports.push_back(record); + break; + } + case MOD_META_HOOK_FN: { + if (size < sizeof(ModMetaHookFn)) { + return invalid("truncated hook record"); + } + parsed.hookFns.push_back( + reinterpret_cast(const_cast(cursor))); + break; + } + case MOD_META_HOOK_MEM: { + if (size <= sizeof(ModMetaHookMem)) { + return invalid("truncated hook record"); + } + auto* record = reinterpret_cast(const_cast(cursor)); + const char* strings = reinterpret_cast(cursor) + sizeof(ModMetaHookMem); + const size_t capacity = size - sizeof(ModMetaHookMem); + if (!terminated_within(strings, capacity)) { + return invalid("unterminated hook vtable symbol"); + } + const size_t vtableLen = std::char_traits::length(strings); + if (!terminated_within(strings + vtableLen + 1, capacity - vtableLen - 1)) { + return invalid("unterminated hook display name"); + } + parsed.hookMems.push_back(record); + break; + } + case MOD_META_HOOK_MEM_EXT: { + if (size <= sizeof(ModMetaHookMemExt)) { + return invalid("truncated extended hook record"); + } + auto* record = reinterpret_cast(const_cast(cursor)); + if (record->pmf_size <= MOD_META_HOOK_MEM_CAPACITY || + record->pmf_size > MOD_META_HOOK_MEM_EXT_CAPACITY || record->materialize == nullptr) + { + return invalid("bad extended hook member-pointer size"); + } + const char* strings = reinterpret_cast(cursor) + sizeof(ModMetaHookMemExt); + const size_t capacity = size - sizeof(ModMetaHookMemExt); + if (!terminated_within(strings, capacity)) { + return invalid("unterminated extended hook vtable symbol"); + } + const size_t vtableLen = std::char_traits::length(strings); + if (!terminated_within(strings + vtableLen + 1, capacity - vtableLen - 1)) { + return invalid("unterminated extended hook display name"); + } + parsed.hookMemExts.push_back(record); + break; + } + case MOD_META_HOOK_NAME: { + if (size <= sizeof(ModMetaHookName)) { + return invalid("truncated hook record"); + } + auto* record = reinterpret_cast(const_cast(cursor)); + const char* name = reinterpret_cast(cursor) + sizeof(ModMetaHookName); + if (!terminated_within(name, size - sizeof(ModMetaHookName))) { + return invalid("unterminated hook symbol name"); + } + parsed.hookNames.push_back(record); + break; + } + default: + // Additive record kinds may appear within a format version; skip them. + log::write(mod.metadata.id, LOG_LEVEL_DEBUG, "skipping unknown metadata record kind {}", + rec->kind); + break; + } + cursor += size; + } + + if (headerCount != 1) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "expected 1 metadata header record, found {}", + headerCount); + mod.nativeStatus = NativeModStatus::InvalidMetadata; + return false; + } + if (parsed.abiVersion != MOD_ABI_VERSION) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "expects ABI v{} but engine is v{}, skipping", + parsed.abiVersion, MOD_ABI_VERSION); + mod.nativeStatus = NativeModStatus::ApiVersionMismatch; + return false; + } + + native.parsed = std::move(parsed); + return true; +} + +std::string native_status_message(const NativeModStatus status) { + switch (status) { + case NativeModStatus::BuildDisabled: + return "Code mods are disabled on this Dusklight build"; + case NativeModStatus::ModMissingPlatform: + return fmt::format("Mod not supported on this platform ({})", k_nativePlatform); + case NativeModStatus::ApiVersionMismatch: + // TODO: differentiate whether mod or Dusklight is out of date + return "Mod ABI version mismatch"; + case NativeModStatus::MissingExport: + return "Missing required mod API exports"; + case NativeModStatus::InvalidMetadata: + return "Invalid mod metadata records"; + case NativeModStatus::InvalidBundle: + return "Invalid mod bundle layout (old mod?)"; + case NativeModStatus::Unknown: + return "Unknown mod load failure"; + case NativeModStatus::None: + case NativeModStatus::Loaded: + break; + } + return "native mod failed to load"; +} + +} // namespace + +fs::path ModLoader::external_native_lib_path(const LoadedMod& mod) const { + if (k_nativeLibName.empty()) { + return {}; + } + const auto& libDir = m_searchDirs[mod.searchDirIndex].nativeLibDir; + if (libDir.empty()) { + return {}; + } + const auto filename = fmt::format("{}{}", mod.metadata.id, + borealis::io::fs_path_to_string(fs::path{k_nativeLibName}.extension())); + fs::path path = libDir / fs::path{filename}; + std::error_code ec; + if (!fs::is_regular_file(path, ec)) { + return {}; + } + return path; +} + +void ModLoader::load_native( + LoadedMod& mod, const std::string& dllEntry, const std::vector& runtimeEntries) { + if (!EnableCodeMods) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "Code mods are not available in this build"); + mod.nativeStatus = NativeModStatus::BuildDisabled; + return; + } + + const fs::path cacheDir = m_cacheDir / mod.metadata.id; + const fs::path scratchDir = cacheDir / "data"; + std::error_code ec; + fs::create_directories(scratchDir, ec); + if (ec) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to create mod directory {}: {}", + data::abbreviated_path_string(scratchDir), ec.message()); + return; + } + mod.dir = fs::absolute(scratchDir); + mod.dirUtf8 = borealis::io::fs_path_to_string(mod.dir); + + fs::path libPath; + fs::path runtimeDir; + DirectoryRollback runtimeDirRollback; + if (mod.nativeInPlace) { + if (!dllEntry.empty()) { + libPath = mod.modPath / dllEntry; + } else if (auto external = external_native_lib_path(mod); !external.empty()) { + libPath = std::move(external); + } else { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "no native library named {} found; skipping", k_nativeLibName); + mod.nativeStatus = NativeModStatus::ModMissingPlatform; + return; + } + runtimeDir = libPath.parent_path(); + } else { + if (dllEntry.empty()) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "no native library named {} found; skipping", k_nativeLibName); + mod.nativeStatus = NativeModStatus::ModMissingPlatform; + return; + } + + // Every generation gets a new directory. The main module and all of its runtime + // libraries therefore have fresh paths and can coexist with a previous generation + // that is still unwinding after a reload. + runtimeDir = cacheDir / fmt::format("g{}", ++mod.cacheGeneration); + runtimeDirRollback.set_path(runtimeDir); + fs::create_directories(runtimeDir, ec); + if (ec) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "failed to create native runtime directory {}: {}", + data::abbreviated_path_string(runtimeDir), ec.message()); + return; + } + + const std::string platformPrefix = fmt::format("{}{}/", k_nativeLibDir, k_nativePlatform); + for (const auto& entry : runtimeEntries) { + if (!entry.starts_with(platformPrefix)) { + continue; + } + const std::string_view relativeName{ + entry.data() + platformPrefix.size(), entry.size() - platformPrefix.size()}; + if (!is_safe_resource_path(relativeName)) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "unsafe native runtime path '{}'; skipping", entry); + return; + } + + const fs::path outputPath = runtimeDir / fs::path{relativeName}; + fs::create_directories(outputPath.parent_path(), ec); + if (ec) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "failed to create directory for {}: {}", entry, ec.message()); + return; + } + + std::vector data; + try { + data = mod.bundle->readFile(entry); + } catch (const std::exception& e) { + log::write( + mod.metadata.id, LOG_LEVEL_ERROR, "failed to extract {}: {}", entry, e.what()); + return; + } + + std::ofstream out(outputPath, std::ios::binary | std::ios::out); + if (!out) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to write {}", entry); + return; + } + out.write(reinterpret_cast(data.data()), + static_cast(data.size())); + if (!out) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to write {}", entry); + return; + } + } + + libPath = runtimeDir / fs::path{dllEntry}.filename(); + } + + auto nativeMod = std::make_unique(); + try { + nativeMod->handle = std::make_unique(libPath); + } catch (const std::runtime_error& e) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to open {}: {}", + data::abbreviated_path_string(libPath), e.what()); + return; + } + + nativeMod->meta = nativeMod->handle->LookupSymbol("mod_meta"); + nativeMod->contextSymbol = nativeMod->handle->LookupSymbol("mod_ctx"); + nativeMod->fn_initialize = nativeMod->handle->LookupSymbol("mod_initialize"); + nativeMod->fn_update = nativeMod->handle->LookupSymbol("mod_update"); + nativeMod->fn_shutdown = nativeMod->handle->LookupSymbol("mod_shutdown"); + + if (!nativeMod->meta || !nativeMod->contextSymbol || !nativeMod->fn_initialize || + !nativeMod->fn_update || !nativeMod->fn_shutdown) + { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "{} missing required mod API exports; skipping", + data::abbreviated_path_string(libPath)); + mod.nativeStatus = NativeModStatus::MissingExport; + return; + } + + if (!parse_meta(*nativeMod, mod)) { + return; + } + + if (nativeMod->contextSymbol == nullptr) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "missing required mod_ctx export"); + mod.nativeStatus = NativeModStatus::MissingExport; + return; + } + *nativeMod->contextSymbol = mod.context.get(); + + mod.nativePath = fs::absolute(libPath); + mod.nativeDir = fs::absolute(runtimeDir); + mod.nativeDirUtf8 = borealis::io::fs_path_to_string(mod.nativeDir); + mod.native = std::move(nativeMod); + mod.nativeStatus = NativeModStatus::Loaded; + runtimeDirRollback.release(); +} + +bool ModLoader::load_native_if_present(LoadedMod& mod) { + const auto result = locate_native_runtime(*mod.bundle); + if (const auto* failure = std::get_if(&result)) { + mod.nativeStatus = failure->status; + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "{}", failure->logMessage); + fail_mod(mod, MOD_ERROR, native_status_message(failure->status)); + return false; + } + + const auto& native = std::get(result); + if (mod.runtime.has_value() && + (native.anyLibs || (mod.nativeInPlace && !external_native_lib_path(mod).empty()))) + { + mod.nativeStatus = NativeModStatus::InvalidBundle; + fail_mod(mod, MOD_CONFLICT, "A mod cannot declare both runtime and native code"); + return false; + } + if (!native.anyLibs && !(mod.nativeInPlace && !external_native_lib_path(mod).empty())) { + mod.nativeStatus = NativeModStatus::None; + return true; + } + + mod.nativeStatus = NativeModStatus::Unknown; + load_native(mod, native.entry, native.runtimeEntries); + if (mod.nativeStatus != NativeModStatus::Loaded) { + fail_mod(mod, MOD_ERROR, native_status_message(mod.nativeStatus)); + return false; + } + return true; +} + +void ModLoader::unload_native(LoadedMod& mod) { + if (!mod.native) { + return; + } + // Deferred dlclose: this mod's code may still be on the stack below the current tick + m_retiredNatives.push_back( + {std::move(mod.native), mod.nativeInPlace ? fs::path{} : std::move(mod.nativeDir)}); + mod.nativePath.clear(); + mod.nativeDir.clear(); + mod.nativeDirUtf8.clear(); +} + +void ModLoader::drain_retired_natives() { + for (auto& retired : m_retiredNatives) { + retired.native.reset(); + if (!retired.directory.empty()) { + std::error_code ec; + fs::remove_all(retired.directory, ec); + } + } + m_retiredNatives.clear(); +} + +bool ModLoader::ensure_native_loaded(LoadedMod& mod) { + if (mod.native || mod.nativeStatus == NativeModStatus::None) { + return true; + } + return load_native_if_present(mod); +} + +ModManifestInfo build_manifest_info(const ModMetaParsed& parsed) { + ModManifestInfo info; + info.imports.reserve(parsed.imports.size()); + for (const auto* record : parsed.imports) { + if (!svc::valid_service_id(record->service_id.chars)) { + continue; + } + info.imports.push_back({ + .id = record->service_id.chars, + .major = record->major_version, + .minMinor = record->min_minor_version, + .required = (record->rec.flags & SERVICE_IMPORT_OPTIONAL) == 0, + }); + } + info.exports.reserve(parsed.exports.size()); + for (const auto* record : parsed.exports) { + if (!svc::valid_service_id(record->service_id.chars)) { + continue; + } + info.exports.push_back({ + .id = record->service_id.chars, + .major = record->major_version, + }); + } + return info; +} + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/natives.hpp b/src/dusk/mods/loader/natives.hpp new file mode 100644 index 0000000000..657f3d011a --- /dev/null +++ b/src/dusk/mods/loader/natives.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include + +namespace dusk::mods { + +struct ModManifestInfo; +struct ModMetaParsed; + +bool has_native_library_extension(std::string_view name); +ModManifestInfo build_manifest_info(const ModMetaParsed& parsed); + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/packages.cpp b/src/dusk/mods/loader/packages.cpp new file mode 100644 index 0000000000..071f5b276e --- /dev/null +++ b/src/dusk/mods/loader/packages.cpp @@ -0,0 +1,201 @@ +#include "packages.hpp" + +#include "loader.hpp" +#include "manifest.hpp" + +#include "dusk/data.hpp" + +#include +#include +#include +#include + +#include +#include +#include + +namespace fs = std::filesystem; + +namespace dusk::mods { +namespace { + +constexpr borealis::Log Log{"dusk::mods::loader"}; + +} // namespace + +std::unique_ptr load_bundle(const fs::path& modPath, bool fromDir) { + if (fromDir) { + return std::make_unique(modPath); + } else { + return std::make_unique(modPath); + } +} + +int compare_package_versions(std::string_view lhs, std::string_view rhs) { + const auto left = borealis::update::parse_version(lhs); + const auto right = borealis::update::parse_version(rhs); + if (left && right) { + return borealis::update::compare_version(*left, *right); + } + return static_cast(left.has_value()) - static_cast(right.has_value()); +} + +std::vector scan_packages(std::span searchDirs) { + std::vector packages; + for (size_t dirIndex = 0; dirIndex < searchDirs.size(); ++dirIndex) { + const auto& searchDir = searchDirs[dirIndex]; + std::error_code error; + bool alreadyScanned = false; + for (size_t earlier = 0; earlier < dirIndex && !alreadyScanned; ++earlier) { + alreadyScanned = fs::equivalent(searchDirs[earlier].path, searchDir.path, error); + } + if (alreadyScanned || !fs::is_directory(searchDir.path)) { + continue; + } + std::vector entries; + for (const auto& entry : fs::directory_iterator{searchDir.path}) { + if ((entry.is_directory() && fs::exists(entry.path() / "mod.json")) || + (entry.is_regular_file() && entry.path().extension() == ".dusk")) + { + entries.push_back(entry); + } + } + std::ranges::sort(entries, {}, &fs::directory_entry::path); + for (const auto& entry : entries) { + try { + const bool fromDirectory = entry.is_directory(); + auto bundle = load_bundle(entry.path(), fromDirectory); + packages.push_back({ + .path = fs::absolute(entry.path()), + .metadata = load_manifest(entry.path(), *bundle).metadata, + .searchDirIndex = static_cast(dirIndex), + .fromDirectory = fromDirectory, + .symlink = entry.is_symlink(), + }); + } catch (const std::exception& exception) { + Log.error("bad mod package {}: {}", + data::abbreviated_path_string(entry.path()), exception.what()); + } + } + } + return packages; +} + +const PackageCandidate* select_package(std::span packages, + std::string_view modId) { + const PackageCandidate* selected = nullptr; + for (const auto& package : packages) { + if (package.metadata.id != modId) { + continue; + } + const int order = selected ? + compare_package_versions(package.metadata.version, selected->metadata.version) : 1; + if (order > 0 || (order == 0 && + (package.searchDirIndex < selected->searchDirIndex || + (package.searchDirIndex == selected->searchDirIndex && package.path < selected->path)))) + { + selected = &package; + } + } + return selected; +} + +void record_package_sources(LoadedMod& mod, std::span packages) { + mod.hasUserPackage = false; + mod.hasBundledCopy = false; + for (const auto& package : packages) { + if (package.metadata.id != mod.metadata.id) { + continue; + } + if (package.searchDirIndex != 0) { + mod.hasBundledCopy = true; + } else if (!package.fromDirectory && !package.symlink) { + mod.hasUserPackage = true; + } + } +} + +PackageInstallResult install_package(const fs::path& path, const fs::path& destination, + std::string_view modId) { + const auto userDir = destination.parent_path(); + std::error_code error; + std::string validationError; + const auto destinationStatus = fs::symlink_status(destination, error); + if (error == std::errc::no_such_file_or_directory) { + error.clear(); + } + if (error) { + return {.replaced = false, + .error = fmt::format("Could not inspect the destination: {}", error.message())}; + } + if (fs::exists(destinationStatus)) { + ModMetadata existingMetadata; + if (!fs::is_regular_file(destinationStatus) || + !inspect_mod_bundle(destination, existingMetadata, validationError) || + existingMetadata.id != modId) + { + return { + .replaced = false, + .error = "The destination filename is already used by another package", + }; + } + } + std::vector duplicates; + for (fs::directory_iterator entry{userDir, error}, end; !error && entry != end; + entry.increment(error)) + { + const auto& candidate = entry->path(); + if (candidate.extension() != ".dusk") { + continue; + } + const bool regularFile = entry->is_regular_file(error); + if (error) { + break; + } + if (!regularFile) { + continue; + } + ModMetadata candidateMetadata; + if (inspect_mod_bundle(candidate, candidateMetadata, validationError) && + candidateMetadata.id == modId) + { + duplicates.push_back(candidate); + } + } + if (error) { + return {.replaced = false, + .error = fmt::format("Could not scan the mods directory: {}", error.message())}; + } + + std::string replaceError; + if (!borealis::io::atomic_replace(path, destination, replaceError)) { + return { + .replaced = false, + .error = std::move(replaceError), + }; + } + + std::string cleanupError; + for (const auto& duplicate : duplicates) { + // Replacing a file on a case-insensitive filesystem can preserve its old spelling. + const bool destinationAlias = + fs::equivalent(duplicate, destination, error) && !fs::is_symlink(duplicate, error); + error.clear(); + if (destinationAlias) { + if (duplicate.filename() == destination.filename()) { + continue; + } + fs::rename(duplicate, destination, error); + } else { + fs::remove(duplicate, error); + } + if (error) { + cleanupError = fmt::format("Installed, but could not consolidate '{}': {}", + data::abbreviated_path_string(duplicate), error.message()); + Log.warn("{}", cleanupError); + } + } + return {.replaced = true, .error = std::move(cleanupError)}; +} + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/packages.hpp b/src/dusk/mods/loader/packages.hpp new file mode 100644 index 0000000000..0271dca5fe --- /dev/null +++ b/src/dusk/mods/loader/packages.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "dusk/mod_loader.hpp" + +#include +#include +#include +#include +#include +#include + +namespace dusk::mods { + +struct PackageCandidate { + std::filesystem::path path; + ModMetadata metadata; + uint32_t searchDirIndex = 0; + bool fromDirectory = false; + bool symlink = false; +}; + +std::vector scan_packages(std::span searchDirs); +int compare_package_versions(std::string_view lhs, std::string_view rhs); +const PackageCandidate* select_package( + std::span packages, std::string_view modId); +void record_package_sources(LoadedMod& mod, std::span packages); + +std::unique_ptr load_bundle(const std::filesystem::path& modPath, bool fromDir); + +struct PackageInstallResult { + bool replaced = false; + std::string error; +}; + +PackageInstallResult install_package(const std::filesystem::path& path, + const std::filesystem::path& destination, std::string_view modId); + +} // namespace dusk::mods diff --git a/src/dusk/mods/svc/hook.cpp b/src/dusk/mods/svc/hook.cpp index d8a9005deb..725e83ab47 100644 --- a/src/dusk/mods/svc/hook.cpp +++ b/src/dusk/mods/svc/hook.cpp @@ -297,27 +297,32 @@ bool install_backend( #endif } -void deactivate_backend(void* target, InstalledBackend& backend) { +bool deactivate_backend(void* target, InstalledBackend& backend) { #if DUSK_HAS_PREPATCH if (backend.kind == BackendKind::Prepatch) { prepatch::publish(backend.prepatchSite, nullptr); backend = {}; - return; + return true; } #endif #if DUSK_HAS_FUNCHOOK if (backend.kind == BackendKind::Funchook) { const int uninst = funchook_uninstall(backend.handle, 0); + if (uninst != 0) { + DuskLog.warn("HookSystem: funchook uninstall for {:p} failed: {}", target, + funchook_error_message(backend.handle)); + return false; + } const int destr = funchook_destroy(backend.handle); - if (uninst != 0 || destr != 0) { - DuskLog.warn("HookSystem: funchook uninstall/destroy for {:p} returned {}/{}", target, - uninst, destr); + if (destr != 0) { + DuskLog.warn("HookSystem: funchook destroy for {:p} returned {}", target, destr); } } #else (void)target; #endif backend = {}; + return true; } bool handoff_backend( @@ -352,8 +357,8 @@ bool handoff_hook(void* target, InstalledHook& entry) { #else constexpr bool prepatched = false; #endif - if (!prepatched) { - deactivate_backend(target, entry.backend); + if (!prepatched && !deactivate_backend(target, entry.backend)) { + DuskLog.fatal("HookSystem: cannot hand off a hook that remains installed at {:p}", target); } entry.active = nullptr; @@ -535,13 +540,20 @@ ModResult hook_uninstall(ModContext* context, void* fnAddr, void** originalFnSlo return MOD_INVALID_ARGUMENT; } + const bool removedActive = entry.activeStore == originalFnSlot; +#if DUSK_HAS_FUNCHOOK + if (removedActive && entry.backend.kind == BackendKind::Funchook && + !deactivate_backend(fnAddr, entry.backend)) { + return MOD_ERROR; + } +#endif + if (const auto registryIt = s_registry.find(key); registryIt != s_registry.end() && erase_callbacks(registryIt->second, context)) { s_registry.erase(registryIt); } - const bool removedActive = entry.activeStore == originalFnSlot; entry.candidates.erase(candidateIt); *originalFnSlot = nullptr; if (!removedActive) { @@ -897,7 +909,9 @@ void hook_remove_mod(LoadedMod& mod) { auto* target = reinterpret_cast(it->first); if (entry.candidates.empty()) { - deactivate_backend(target, entry.backend); + if (!deactivate_backend(target, entry.backend)) { + DuskLog.fatal("HookSystem: cannot detach a mod with a live hook at {:p}", target); + } it = s_installed.erase(it); continue; } diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index b2206cc60d..85035fd908 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -3,19 +3,22 @@ #include "dusk/app_info.hpp" #include "dusk/logging.h" #include "dusk/mods/loader/loader.hpp" -#include "dusk/mods/log_buffer.hpp" #include +#include +#include #include #include #include +#include #include namespace dusk::mods::svc { namespace { std::unordered_map s_services; +std::unordered_set s_unavailableServices; std::vector s_modules; std::string service_key(std::string_view id, const uint16_t majorVersion) { @@ -48,6 +51,7 @@ bool validate_service_header(const ServiceHeader* header, const char* serviceId, void clear_services() { s_services.clear(); + s_unavailableServices.clear(); s_modules.clear(); } @@ -137,8 +141,38 @@ const ServiceRecord* find_service_record(const char* serviceId, const uint16_t m return it != s_services.end() ? &it->second : nullptr; } +std::string describe_missing_service(const char* serviceId, const uint16_t majorVersion, + const uint16_t minMinorVersion) { + const char* message = "Mod requires a service that is unavailable"; + if (std::string_view{serviceId}.starts_with(DUSKLIGHT_SERVICE_ID_PREFIX) && + !s_unavailableServices.contains(service_key(serviceId, majorVersion))) + { + if (const auto* record = find_service_record(serviceId, majorVersion)) { + if (record->provider == nullptr && record->service != nullptr && + record->minorVersion < minMinorVersion) + { + message = "Mod requires a newer Dusklight version"; + } + } else { + std::optional highestMajor; + for (const auto& [key, record] : s_services) { + if (record.provider == nullptr && record.service != nullptr && record.id == serviceId) { + highestMajor = std::max(highestMajor.value_or(0), record.majorVersion); + } + } + if (highestMajor) { + message = majorVersion > *highestMajor ? + "Mod requires a newer Dusklight version" : + "Mod must be updated for the current Dusklight version"; + } + } + } + return fmt::format("{} (missing: {})", message, serviceId); +} + ModResult register_module(const ServiceModule& module) { if (module.available != nullptr && !module.available()) { + s_unavailableServices.insert(service_key(module.id, module.majorVersion)); return MOD_UNAVAILABLE; } const auto result = register_service( @@ -146,6 +180,7 @@ ModResult register_module(const ServiceModule& module) { if (result != MOD_OK) { return result; } + s_unavailableServices.erase(service_key(module.id, module.majorVersion)); s_modules.push_back(&module); if (module.initialize != nullptr) { module.initialize(); @@ -271,6 +306,9 @@ bool ModLoader::register_static_service_exports(LoadedMod& mod) { std::string ModLoader::describe_missing_import( const char* serviceId, const uint16_t majorVersion, const uint16_t minMinorVersion) const { + if (std::string_view{serviceId}.starts_with(DUSKLIGHT_SERVICE_ID_PREFIX)) { + return svc::describe_missing_service(serviceId, majorVersion, minMinorVersion); + } if (const auto* record = svc::find_service_record(serviceId, majorVersion)) { if (record->service == nullptr) { return fmt::format("Required service {}@{} was never published by provider '{}'", @@ -297,7 +335,7 @@ std::string ModLoader::describe_missing_import( } } - return fmt::format("Required service unavailable: {}@{}", serviceId, majorVersion); + return svc::describe_missing_service(serviceId, majorVersion, minMinorVersion); } bool ModLoader::resolve_service_imports(LoadedMod& mod) { @@ -321,9 +359,7 @@ bool ModLoader::resolve_service_imports(LoadedMod& mod) { continue; } - mod.active = false; - mod.suspendedByProvider = true; - log::write(mod.metadata.id, LOG_LEVEL_INFO, "suspended: {}", + fail_mod(mod, MOD_UNAVAILABLE, describe_missing_import(serviceImport->service_id.chars, serviceImport->major_version, serviceImport->min_minor_version)); return false; diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index 470208fad1..ac524384f3 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -58,6 +58,8 @@ const ServiceRecord* find_service( const char* serviceId, uint16_t majorVersion, uint16_t minMinorVersion); // Unlike find_service, also returns deferred records that have not been published yet. const ServiceRecord* find_service_record(const char* serviceId, uint16_t majorVersion); +std::string describe_missing_service(const char* serviceId, uint16_t majorVersion, + uint16_t minMinorVersion); ModResult register_module(const ServiceModule& module); void modules_mod_deactivating(LoadedMod& mod); diff --git a/src/dusk/ui/context_menu.cpp b/src/dusk/ui/context_menu.cpp new file mode 100644 index 0000000000..1799b4a6ad --- /dev/null +++ b/src/dusk/ui/context_menu.cpp @@ -0,0 +1,76 @@ +#include "context_menu.hpp" + +#include "button.hpp" +#include "icon_button.hpp" + +namespace dusk::ui { + +ContextMenu::Binding::Binding(Document& owner, Rml::Element* root, Rml::String selector, + std::function(Rml::Element*)> items) + : mMouseDown{root, Rml::EventId::Mousedown, + [this, &owner, root, selector = std::move(selector), items = std::move(items)]( + Rml::Event& event) { + if (event.GetParameter("button", -1) != 1 || !owner.active()) { + return; + } + auto* target = event.GetTargetElement()->Closest(selector); + if (target == nullptr || !root->Contains(target)) { + return; + } + auto menuItems = items(target); + if (menuItems.empty()) { + return; + } + dismiss(); + auto menu = std::make_unique(target, std::move(menuItems), + Rml::Vector2f{ + event.GetParameter("mouse_x", 0), + event.GetParameter("mouse_y", 0), + }); + mMenu = menu.get(); + mMenu->on_close([this] { mMenu = nullptr; }); + push_document(std::move(menu)); + event.StopPropagation(); + }} {} + +ContextMenu::Binding::~Binding() { + dismiss(); +} + +void ContextMenu::Binding::dismiss() { + if (mMenu != nullptr) { + mMenu->on_close(nullptr); + mMenu->dismiss(); + mMenu = nullptr; + } +} + +ContextMenu::ContextMenu( + Rml::Element* anchor, std::vector items, std::optional position) + : Popover{anchor, Side::Below, "context-menu"}, + mNavigation{body(), {.horizontalBoundary = NavGroup::Boundary::Stop, + .verticalBoundary = NavGroup::Boundary::Stop}} { + if (position) { + set_position(*position); + } + for (auto& item : items) { + if (item.separatorBefore && body()->GetNumChildren() != 0) { + append(body(), "menu-separator"); + } + auto& button = mNavigation.add_item