From 3ac0fcb15681bbc595cf6ce06df9b4aeaad77fd9 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Thu, 6 Aug 2026 23:59:26 -0600 Subject: [PATCH] Add mods::hook::uninstall --- sdk/include/mods/hook.hpp | 10 +++ sdk/include/mods/svc/hook.h | 47 +++++++---- sdk/include/mods/svc/hook.hpp | 22 +++++ src/dusk/mods/svc/hook.cpp | 152 ++++++++++++++++++++++++---------- 4 files changed, 175 insertions(+), 56 deletions(-) diff --git a/sdk/include/mods/hook.hpp b/sdk/include/mods/hook.hpp index 6087ff49bb..d0e793204c 100644 --- a/sdk/include/mods/hook.hpp +++ b/sdk/include/mods/hook.hpp @@ -53,4 +53,14 @@ ModResult hook_replace(HookReplaceFn callback, const HookOptions* options = null return hook::replace(callback, options); } +template +ModResult hook_uninstall(const HookService* hooks) { + return hook::uninstall(hooks); +} + +template +ModResult hook_uninstall() { + return hook::uninstall(); +} + } // namespace mods diff --git a/sdk/include/mods/svc/hook.h b/sdk/include/mods/svc/hook.h index e9c64b1ca4..288db3cc14 100644 --- a/sdk/include/mods/svc/hook.h +++ b/sdk/include/mods/svc/hook.h @@ -7,20 +7,22 @@ #endif /* - * Intercept game functions by address. Prefer the typed helpers in mods/svc/hook.hpp - * (mods::hook::add_pre/add_post/replace over a &Class::method): they generate the - * trampoline and hide install/dispatch, which are the low-level primitives those helpers - * build. resolve() maps a symbol name to an address for targets you can't name at compile time - * (file-local statics included). + * Hooks allow intercepting calls to game functions, allowing you to: + * - Modify arguments + * - Perform your own work before (pre), after (post) or instead of (replace) the original call + * - From a pre hook, conditionally skip the original call and return your own value * - * Every call is game-thread-only. Install and removal must run with no hooked function on the - * stack; the loader guarantees this by applying mod lifecycle changes between frames, which is - * why hooking a function that never returns (the outermost loop) makes a mod un-unloadable. + * In most cases, you'll want to instead use the C++ helpers in mods/svc/hook.hpp + * (mods::hook::add_pre/add_post/replace). They generate the trampoline passed to + * install and provide compile-time type checking. + * + * resolve() resolves an address by symbol name for targets you can't name at compile time + * (file-local statics included). */ #define HOOK_SERVICE_ID "dev.twilitrealm.dusklight.hook" #define HOOK_SERVICE_MAJOR 1u -#define HOOK_SERVICE_MINOR 0u +#define HOOK_SERVICE_MINOR 1u /* Symbol flags reported by resolve() */ typedef enum HookSymbolFlags { @@ -72,11 +74,18 @@ typedef struct HookService { ServiceHeader header; /* - * Install a trampoline detour on fn_addr and return the address to call the original through in - * *out_original_fn. The typed helpers generate the trampoline and call this; mods normally - * don't. The first mod to install a given target owns the live detour; later mods register as - * candidates so a hook survives the owner unloading (the detour is handed off and every - * original pointer is rewritten). Idempotent per (mod, out slot). + * Install a hook on fn_addr. + * + * trampoline_fn must point to a function that matches the original function's signature and + * dispatches pre- and post- hooks. This dispatch trampoline is normally generated at compile + * time using C++ template instantiation (see mods/svc/hook.hpp). + * + * The first hook install on a target will implicitly install a detour (patched instructions + * on the target that jump to the dispatch trampoline). When all hooks are uninstalled from a + * target, the detour is completely uninstalled. + * + * The address that the dispatch trampoline should call the original function through is written + * to out_original_fn. */ ModResult (*install)( ModContext* ctx, void* fn_addr, void* trampoline_fn, void** out_original_fn); @@ -116,6 +125,16 @@ typedef struct HookService { */ ModResult (*resolve)( ModContext* ctx, const char* symbol, void** out_addr, HookSymbolFlags* out_flags); + + /* Minor version 1 */ + + /* + * Uninstall the current mod's hook on fn_addr and unregister all callbacks. + * If no other mods have a hook installed on the target, the detour is uninstalled entirely. + * + * original_fn_slot must match the out_original_fn passed to install. + */ + ModResult (*uninstall)(ModContext* ctx, void* fn_addr, void** original_fn_slot); } HookService; MOD_DECLARE_SERVICE(HookService, svc_hook, HOOK_SERVICE_ID, HOOK_SERVICE_MAJOR, HOOK_SERVICE_MINOR); diff --git a/sdk/include/mods/svc/hook.hpp b/sdk/include/mods/svc/hook.hpp index e61562da36..eab3115d01 100644 --- a/sdk/include/mods/svc/hook.hpp +++ b/sdk/include/mods/svc/hook.hpp @@ -238,5 +238,27 @@ ModResult replace(HookReplaceFn callback, const HookOptions* options = nullptr) return replace(svc_hook, callback, options); } +template +ModResult uninstall(const HookService* hooks) { + if (hooks == nullptr || !SERVICE_HAS(hooks, HookService, uninstall) || + hooks->uninstall == nullptr || Entry::target == nullptr) + { + return MOD_UNAVAILABLE; + } + + const ModResult result = + hooks->uninstall(mod_ctx, Entry::target, reinterpret_cast(&Entry::g_orig)); + if (result == MOD_OK) { + Entry::hooks = nullptr; + Entry::g_orig = nullptr; + } + return result; +} + +template +ModResult uninstall() { + return uninstall(svc_hook); +} + } // namespace hook } // namespace mods diff --git a/src/dusk/mods/svc/hook.cpp b/src/dusk/mods/svc/hook.cpp index ec967ae7e6..d8a9005deb 100644 --- a/src/dusk/mods/svc/hook.cpp +++ b/src/dusk/mods/svc/hook.cpp @@ -86,6 +86,7 @@ struct InstalledHook { InstalledBackend backend{}; void* original = nullptr; ModContext* active = nullptr; + void** activeStore = nullptr; std::vector candidates; }; @@ -143,6 +144,15 @@ void sort_hooks(std::vector& hooks) { }); } +bool erase_callbacks(HookSlot& slot, ModContext* context) { + std::erase_if(slot.pre, [&](const PreHookFn& hook) { return hook.context == context; }); + std::erase_if(slot.post, [&](const VoidHookFn& hook) { return hook.context == context; }); + if (slot.replace.context == context) { + slot.replace = {}; + } + return slot.pre.empty() && slot.post.empty() && slot.replace.replaceCallback == nullptr; +} + // Once a hook is installed, funchook has patched the target's entry: its bytes lead to the // detour, not the function, so canonicalization must stop there. [[maybe_unused]] bool installed_target(void* addr) { @@ -336,6 +346,46 @@ bool handoff_backend( #endif } +bool handoff_hook(void* target, InstalledHook& entry) { +#if DUSK_HAS_PREPATCH + const bool prepatched = entry.backend.kind == BackendKind::Prepatch; +#else + constexpr bool prepatched = false; +#endif + if (!prepatched) { + deactivate_backend(target, entry.backend); + } + + entry.active = nullptr; + entry.activeStore = nullptr; + for (auto& candidate : entry.candidates) { + void* original = nullptr; + if (!handoff_backend(target, entry, candidate, &original)) { + continue; + } + entry.original = original; + entry.active = candidate.context; + entry.activeStore = candidate.origStore; + break; + } + + if (entry.active == nullptr) { + DuskLog.warn("HookSystem: no reinstallable trampoline for {:p}; hooks there are " + "disabled until a mod reinstalls one", + target); + for (auto& candidate : entry.candidates) { + *candidate.origStore = target; + } + deactivate_backend(target, entry.backend); + return false; + } + + for (auto& candidate : entry.candidates) { + *candidate.origStore = entry.original; + } + return true; +} + ModResult hook_install(ModContext* context, void* fnAddr, void* trampolineFn, void** outOriginal) { if (fnAddr == nullptr || trampolineFn == nullptr || outOriginal == nullptr) { return MOD_INVALID_ARGUMENT; @@ -390,6 +440,7 @@ ModResult hook_install(ModContext* context, void* fnAddr, void* trampolineFn, vo entry.backend = backend; entry.original = *outOriginal; entry.active = context; + entry.activeStore = outOriginal; entry.candidates.push_back({context, trampolineFn, outOriginal, s_nextOrder++}); return MOD_OK; } @@ -459,6 +510,57 @@ ModResult hook_replace( return MOD_INVALID_ARGUMENT; } +ModResult hook_uninstall(ModContext* context, void* fnAddr, void** originalFnSlot) { + if (context == nullptr || fnAddr == nullptr || originalFnSlot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + fnAddr = resolve_target(fnAddr); + if (!declared_target(context, fnAddr)) { + return reject_undeclared(context, fnAddr); + } + + const auto key = reinterpret_cast(fnAddr); + const auto installedIt = s_installed.find(key); + if (installedIt == s_installed.end()) { + return MOD_INVALID_ARGUMENT; + } + + auto& entry = installedIt->second; + const auto candidateIt = + std::ranges::find_if(entry.candidates, [&](const HookCandidate& candidate) { + return candidate.context == context && candidate.origStore == originalFnSlot; + }); + if (candidateIt == entry.candidates.end()) { + return MOD_INVALID_ARGUMENT; + } + + 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) { + return MOD_OK; + } + + auto* target = reinterpret_cast(key); + if (entry.candidates.empty()) { + deactivate_backend(target, entry.backend); + s_installed.erase(installedIt); + return MOD_OK; + } + if (!handoff_hook(target, entry)) { + s_installed.erase(installedIt); + return MOD_ERROR; + } + return MOD_OK; +} + ModResult hook_dispatch_pre( ModContext*, void* fnAddr, void* args, void* retval, int* outSkipOriginal) { if (outSkipOriginal != nullptr) { @@ -775,13 +877,7 @@ void hook_remove_mod(LoadedMod& mod) { s_declaredTargets.erase(context); for (auto it = s_registry.begin(); it != s_registry.end();) { - auto& slot = it->second; - std::erase_if(slot.pre, [&](const PreHookFn& hook) { return hook.context == context; }); - std::erase_if(slot.post, [&](const VoidHookFn& hook) { return hook.context == context; }); - if (slot.replace.context == context) { - slot.replace = {}; - } - if (slot.pre.empty() && slot.post.empty() && slot.replace.replaceCallback == nullptr) { + if (erase_callbacks(it->second, context)) { it = s_registry.erase(it); } else { ++it; @@ -806,44 +902,12 @@ void hook_remove_mod(LoadedMod& mod) { continue; } - // A prepatch may be atomically updated directly. - // Funchook must first restore the original instructions before reinstalling. -#if DUSK_HAS_PREPATCH - const bool prepatched = entry.backend.kind == BackendKind::Prepatch; -#else - constexpr bool prepatched = false; -#endif - if (!prepatched) { - deactivate_backend(target, entry.backend); - } - entry.active = nullptr; - for (auto& cand : entry.candidates) { - void* original = nullptr; - if (!handoff_backend(target, entry, cand, &original)) { - continue; - } - entry.original = original; - entry.active = cand.context; - DuskLog.info("HookSystem: replaced trampoline for {:p}: {} -> {} (tramp={:p})", target, - mod_id_from_context(context), mod_id_from_context(cand.context), cand.trampoline); - break; - } - - if (entry.active == nullptr) { - DuskLog.warn("HookSystem: no reinstallable trampoline for {:p}; hooks there are " - "disabled until a mod reinstalls one", - target); - for (auto& cand : entry.candidates) { - *cand.origStore = target; - } - deactivate_backend(target, entry.backend); + if (!handoff_hook(target, entry)) { it = s_installed.erase(it); continue; } - - for (auto& cand : entry.candidates) { - *cand.origStore = entry.original; - } + DuskLog.info("HookSystem: replaced trampoline for {:p}: {} -> {}", target, + mod_id_from_context(context), mod_id_from_context(entry.active)); ++it; } } @@ -862,6 +926,9 @@ ModResult hook_add_post(ModContext*, void*, HookPostFn, const HookOptions*) { ModResult hook_replace(ModContext*, void*, HookReplaceFn, const HookOptions*) { return MOD_UNSUPPORTED; } +ModResult hook_uninstall(ModContext*, void*, void**) { + return MOD_UNSUPPORTED; +} ModResult hook_dispatch_pre(ModContext*, void*, void*, void*, int* outSkipOriginal) { if (outSkipOriginal != nullptr) { *outSkipOriginal = 0; @@ -902,6 +969,7 @@ constexpr HookService s_hookService{ .dispatch_pre = hook_dispatch_pre, .dispatch_post = hook_dispatch_post, .resolve = hook_resolve, + .uninstall = hook_uninstall, }; } // namespace