GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 93.9% 155 / 0 / 165
Functions: 97.9% 47 / 0 / 48
Branches: 62.1% 41 / 0 / 66

include/DetourModKit/hook.hpp
Line Branch Exec Source
1 #ifndef DETOURMODKIT_HOOK_HPP
2 #define DETOURMODKIT_HOOK_HPP
3
4 /**
5 * @file hook.hpp
6 * @brief The hooking surface: free verbs returning move-only RAII handles, with the SafetyHook backend hidden.
7 * @details Inline and mid installation is a two-step transaction under `[B-83]`. @ref inline_at, @ref mid_at, and
8 * @ref install_all return a disabled hook, and `Hook::enable()` arms it. @ref vmt_for is live at creation.
9 * @ref inline_at and @ref mid_at own the split in callback responsibility between the two families. A
10 * translation unit that includes only this header pulls in neither SafetyHook nor Zydis.
11 *
12 * LOADER-LOCK PRECONDITION: do not call a hook mutation operation from DllMain or from any thread that
13 * holds the Windows loader lock. Every install, toggle, batch, VMT creation, and VMT mutation entry returns
14 * @ref ErrorCode::LoaderLockActive before its own object-gate, ledger, backend, allocation, or protection
15 * work. Argument construction at the call site stays the caller's. The Hook and VmtHook destructors retain
16 * unsafe state instead of a wait. See their notes.
17 *
18 * LEDGER SCOPE: duplicate detection and same-target layer order live in a ledger held per linked
19 * DetourModKit instance, not per process. DetourModKit is a static archive, so two DLLs that each link it
20 * hold two independent ledgers, and a hook another kit placed on the same target is invisible here. A
21 * ledger duplicate refuses with @ref ErrorCode::TargetAlreadyHookedByThisKit, which the caller answers by
22 * dropping the handle it already holds. @ref Options::fail_if_already_hooked covers part of that blind spot
23 * without the ledger: it decodes the target's prologue for a foreign JMP and refuses with
24 * @ref ErrorCode::TargetAlreadyHookedByAnotherModule. Nothing recovers layer order across instances, so
25 * cross-instance stacking has no defined teardown order.
26 */
27
28 #include "DetourModKit/address.hpp"
29 #include "DetourModKit/error.hpp"
30 #include "DetourModKit/scan.hpp"
31
32 #include <array>
33 #include <atomic>
34 #include <cstddef>
35 #include <cstdint>
36 #include <cstring>
37 #include <memory>
38 #include <mutex>
39 #include <span>
40 #include <string>
41 #include <string_view>
42 #include <type_traits>
43 #include <utility>
44 #include <variant>
45 #include <vector>
46
47 namespace DetourModKit
48 {
49 namespace hook
50 {
51 /**
52 * @struct MidContext
53 * @brief Opaque handle for the CPU register state captured at a mid-hook site.
54 * @details Deliberately left INCOMPLETE: it is never defined in any translation unit. The accessors
55 * reinterpret_cast a MidContext& to the live backend-context reference and back. This cast is
56 * well-defined ONLY while the type stays incomplete. A CI grep gate forbids a definition.
57 */
58 struct MidContext;
59
60 /**
61 * @brief DMK-owned mid-hook detour signature.
62 * @details Names only DMK types, so writing a detour pulls in neither SafetyHook nor Zydis.
63 * @warning MUST NOT THROW (`[B-84]`). DMK contains an exception that escapes. It counts the escape, logs once
64 * per site, and treats the callback as complete with the context in its last callback-defined state.
65 * Containment is a safety net for a bug, not a contract to program against.
66 * @note Re-entering the hooked target from inside the callback is supported.
67 * @warning Destroying the callback's own Hook from inside it is permitted but pins the backend; see @ref Hook.
68 */
69 using MidHookFn = void (*)(MidContext &);
70
71 /**
72 * @enum Gpr
73 * @brief Selects a general-purpose register for gpr() read/write access at a mid-hook site.
74 * @details rsp and rip are intentionally absent from this set. rsp is read-only at the capture point (the
75 * backend documents that writing it has no effect; query it with stack_pointer() and rewrite the
76 * resume stack with resume_stack_pointer()), and rip is control flow with its own writable accessor
77 * instruction_pointer(). Everything here is a plain integer register the detour may both read and
78 * overwrite, and the overwrite survives the trampoline resume.
79 */
80 enum class Gpr : std::uint8_t
81 {
82 Rax,
83 Rbx,
84 Rcx,
85 Rdx,
86 Rsi,
87 Rdi,
88 Rbp,
89 R8,
90 R9,
91 R10,
92 R11,
93 R12,
94 R13,
95 R14,
96 R15
97 };
98
99 /**
100 * @struct XmmView
101 * @brief Read-only by-value snapshot of one 128-bit XMM register captured at the mid-hook site.
102 * @details DMK copies the 16 bytes out by value rather than hand out a mutable reference into the live
103 * context. lane<T>(index) reinterprets the captured bytes as the caller's lane type, for example
104 * lane<float>(0) for the first single-precision lane.
105 */
106 struct alignas(16) XmmView
107 {
108 std::array<std::byte, 16> bytes;
109
110 /**
111 * @brief Reinterprets the captured bytes as the index-th T-sized lane; an out-of-range lane returns zero.
112 * @tparam T A trivially-copyable scalar lane type (float, double, an integer). memcpy-ing the raw register
113 * bytes into a non-trivially-copyable T would be undefined behaviour, so the type is constrained.
114 * @note Callback-safe: a pure read over the captured context, no allocation, locking, or I/O.
115 */
116 5 template <typename T> [[nodiscard]] T lane(std::size_t index) const noexcept
117 {
118 static_assert(
119 std::is_trivially_copyable_v<T>,
120 "XmmView::lane<T> requires a trivially-copyable lane type"
121 );
122 5 T value{};
123 // Fail closed on an out-of-range lane: a bad index must not read past the 16-byte register.
124
3/4
float DetourModKit::hook::XmmView::lane<float>(unsigned long long) const:
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 6 taken 2 times.
unsigned long long DetourModKit::hook::XmmView::lane<unsigned long long>(unsigned long long) const:
✓ Branch 4 → 5 taken 1 time.
✗ Branch 4 → 6 not taken.
10 if (index >= bytes.size() / sizeof(T))
125 {
126 3 return value;
127 }
128 2 std::memcpy(&value, bytes.data() + index * sizeof(T), sizeof(T));
129 2 return value;
130 }
131 };
132
133 // The mid-hook register set mirrors the Win64 capture context one-to-one; the accessors below reinterpret an
134 // opaque MidContext& as that 64-bit layout, so they are meaningful only on Windows x64.
135 static_assert(sizeof(void *) == 8, "MidContext register set is Windows x64 only");
136
137 /**
138 * @brief Returns a mutable reference to a captured general-purpose register.
139 * @details Reading observes the live argument/scratch register at the hook point; writing overwrites it and
140 * the new value survives the trampoline resume. Defined in src/hook_mid_context.cpp, inside the
141 * backend island; it reinterpret_casts the opaque MidContext& back to the real captured-context
142 * reference.
143 * @note Callback-safe: a pure register read/write over the captured context, no allocation, locking, or I/O.
144 */
145 [[nodiscard]] std::uintptr_t &gpr(MidContext &ctx, Gpr reg) noexcept;
146
147 /**
148 * @brief Returns the captured stack pointer (rsp); read-only by backend contract, modifying it has no effect.
149 * @note Callback-safe: a pure register read over the captured context, no allocation, locking, or I/O.
150 */
151 [[nodiscard]] std::uintptr_t stack_pointer(const MidContext &ctx) noexcept;
152
153 /**
154 * @brief Returns a mutable reference to the captured resume stack pointer (the backend's trampoline_rsp).
155 * @details Unlike rsp (read-only, see stack_pointer), this is the stack pointer the trampoline restores when
156 * it resumes the original code, so writing it relocates the stack the resumed body runs on. A detour
157 * reaches for this accessor when it must adjust where execution resumes.
158 * @note Callback-safe: a pure register read/write over the captured context, no allocation, locking, or I/O.
159 */
160 [[nodiscard]] std::uintptr_t &resume_stack_pointer(MidContext &ctx) noexcept;
161
162 /**
163 * @brief Returns a mutable reference to the captured instruction pointer (rip).
164 * @details Writing it redirects execution on resume: the trampoline's terminal return pops this (possibly
165 * rewritten) slot, so storing another same-signature function's address makes the resume land there
166 * instead of the original body.
167 * @note Callback-safe: a pure register read/write over the captured context, no allocation, locking, or I/O.
168 */
169 [[nodiscard]] std::uintptr_t &instruction_pointer(MidContext &ctx) noexcept;
170
171 /**
172 * @brief Returns a mutable reference to the captured flags register (rflags).
173 * @details Writing it alters the condition flags the trampoline restores on resume, so a detour can flip a
174 * comparison result the original code is about to branch on.
175 * @note Callback-safe: a pure register read/write over the captured context, no allocation, locking, or I/O.
176 */
177 [[nodiscard]] std::uintptr_t &flags(MidContext &ctx) noexcept;
178
179 /**
180 * @brief Read-only by-value snapshot of XMM register @p index (0..15); out-of-range returns a zeroed view.
181 * @note Callback-safe: a pure register read over the captured context, no allocation, locking, or I/O.
182 * @warning The mid-hook frame saves and restores XMM0-15 only. It does not preserve YMM/ZMM upper state,
183 * ZMM16-31, opmask registers, x87, MMX, or complete MXCSR state. A detour must not clobber that state.
184 */
185 [[nodiscard]] XmmView xmm(const MidContext &ctx, std::size_t index) noexcept;
186
187 /**
188 * @enum Prologue
189 * @brief Escalation policy for a target whose prologue is a breakpoint rather than a function body.
190 * @details A leading 0xCC/0xCD (int3 / int n) means the slot is a breakpoint stub, a patched byte, or alignment
191 * padding. @ref Fail refuses the create with @ref ErrorCode::TargetPrologueUnsafe; @ref Relocate logs
192 * and installs anyway.
193 * @note This policy governs only the prologue's shape. Whether the prologue can be relocated at all is left to
194 * the backend's own decode rather than guessed from its first byte, so a relative call is not refused
195 * here; if the backend cannot relocate it, the create fails with @ref ErrorCode::BackendFailed and the
196 * backend's specific reason is logged rather than returned. A target whose bytes are not readable
197 * executable committed memory is refused under BOTH policies: @ref Relocate cannot authorize decoding
198 * non-code.
199 */
200 enum class Prologue : std::uint8_t
201 {
202 Fail,
203 Relocate
204 };
205
206 /**
207 * @enum Severity
208 * @brief Per-row policy for a declarative @ref HookSpec inside @ref install_all.
209 * @details Folds the mandatory-vs-best-effort if-tree of a hand-rolled install loop into a field. A
210 * @ref Mandatory miss fails the whole @ref install_all call; a @ref BestEffort miss warns, records
211 * the per-row Error, skips, and lets the call still succeed.
212 */
213 enum class Severity : std::uint8_t
214 {
215 BestEffort,
216 Mandatory
217 };
218
219 /**
220 * @struct Options
221 * @brief Per-hook policy for @ref inline_at / @ref mid_at.
222 */
223 struct Options
224 {
225 /// Prologue escalation policy; defaults to the safe-by-default Fail (see @ref Prologue).
226 Prologue prologue = Prologue::Fail;
227
228 /**
229 * @brief Refuse the install when the target already appears hooked.
230 * @details The pre-flight first consults this instance's ledger for an exact same-kit hook at the target
231 * address, then falls back to a foreign-JMP heuristic: an E9 rel32 jump, an FF25 indirect jump,
232 * or a mov rax, imm64; jmp rax absolute-jump trampoline planted over the prologue, each decoded
233 * under a fault guard. The default (false) installs anyway and the new hook layers on top.
234 */
235 bool fail_if_already_hooked = false;
236 };
237
238 namespace detail
239 {
240 /// Satisfied only by a pointer-to-function type; the valid cast target for Hook::original.
241 template <typename T>
242 concept FunctionPointer = std::is_pointer_v<T> && std::is_function_v<std::remove_pointer_t<T>>;
243 } // namespace detail
244
245 /**
246 * @brief Where a hook installs: an absolute @ref Address, or a deferred @ref scan::OwnedScanRequest.
247 * @details An owning OwnedScanRequest (never a borrowed ScanRequest) is used for the deferred case so the
248 * stored request closes the span-dangling hazard; it is resolved to an address at install time via
249 * scan::resolve.
250 */
251 using Target = std::variant<Address, scan::OwnedScanRequest>;
252
253 /// A request to install one inline hook by @ref inline_at.
254 struct InlineRequest
255 {
256 std::string name;
257 Target target;
258 Options options{};
259 };
260
261 /// A request to install one mid hook by @ref mid_at.
262 struct MidRequest
263 {
264 std::string name;
265 Target target;
266 Options options{};
267 };
268
269 class Hook;
270
271 namespace detail
272 {
273 /// The non-template inline-install primitive; @ref inline_at funnels its typed detour through this.
274 [[nodiscard]] Result<Hook> inline_at_raw(InlineRequest request, void *detour);
275 } // namespace detail
276
277 /**
278 * @class Hook
279 * @brief Move-only RAII handle for one installed inline or mid hook; its destructor restores the prologue.
280 * @details Constructed by @ref inline_at, @ref mid_at, or @ref install_all, always DISABLED; @ref enable arms
281 * it. Dropping the handle unhooks; @ref release intentionally leaves the hook installed for the
282 * process lifetime.
283 * @note Teardown ordering: when two hooks are layered on the same target address, the newer one must be
284 * destroyed first. Use @ref HookStack when layered hooks live in a container. If the ledger detects an
285 * inversion, teardown leaks the older installed backend to preserve the newer trampoline chain and logs
286 * a warning. The target remains tracked as hooked.
287 * @note Lock order: a toggle takes the per-hook call gate before it claims the HookLedger target slot.
288 * It releases the target slot before the call gate. Logs and lifecycle events follow both releases.
289 */
290 class Hook
291 {
292 public:
293 Hook(Hook &&other) noexcept;
294 Hook &operator=(Hook &&other) noexcept;
295 Hook(const Hook &) = delete;
296 Hook &operator=(const Hook &) = delete;
297
298 /**
299 * @brief Restores the patched prologue when safe; published x64 mid routes retain their route
300 * storage.
301 * @details Original bytes authorize backend destruction even after a failed restore. Foreign or
302 * unreadable bytes do not. A published x64 MID route permanently retains its gateway, inline
303 * trampoline, allocator blocks, and unwind metadata. Clean teardown can still reclaim its mid
304 * stub and adapter after rundown. Under the loader lock, below a newer layer, or without an
305 * Original witness, the whole backend and module reference are pinned, the target stays tracked
306 * as hooked, and `[B-73]` books the leak to
307 * @ref DetourModKit::diagnostics::LeakSubsystem::HookManager.
308 *
309 * For a MID hook this also runs the callback down. The callback retires first, so a pinned hook
310 * goes INERT instead of a call into a destroyed owner. An authorized caller waits for callbacks
311 * already in flight on every teardown branch, and on the restoring path also waits for every
312 * adapter body to leave before the stub is freed. After this returns, no new mid-hook callback
313 * begins.
314 * @note An unauthorized teardown, where an unload phase is published or the fail-closed loader-lock probe
315 * vetoes, pins without a wait. A callback that began before teardown can still finish.
316 * @warning Destruction of a mid hook from INSIDE its own callback cannot wait, because the waiter is the
317 * thread it waits for. DMK detects that case, retires the callback, pins the backend, and books
318 * the leak. Destroy from a thread that is not inside the hook. Teardown pins the same way
319 * whenever it cannot prove that no thread is inside the callback, so a pin alone is not evidence
320 * of misuse.
321 * @warning An INLINE hook has no such rundown; quiescence is caller-owned (see @ref inline_at).
322 * @note This runs from DLL_PROCESS_DETACH / loader-lock teardown, where an escaping exception terminates
323 * the host, so every path inside fails closed rather than propagating.
324 * @note Setup/control-plane only: teardown mutates the target and can wait for in-flight mid-hook
325 * callbacks.
326 */
327 ~Hook() noexcept;
328
329 /// True while this handle owns a live hook (false after a move-out or @ref release).
330 [[nodiscard]] explicit operator bool() const noexcept;
331
332 /// The hook's registered name (empty for a moved-from / released handle).
333 [[nodiscard]] std::string_view name() const noexcept;
334
335 /**
336 * @brief True when the hook is armed or conservatively retained as possibly reachable.
337 *
338 * @details Answers from DMK's published state AND the backend's reconciled view. The backend flag changes
339 * when its mutation commits. If a restore commits but the final byte witness is Foreign or
340 * Indeterminate, DMK retains that flag because a newer layer may still reach the trampoline. A
341 * later retry over exact OwnedPatch bytes can then perform the real restore. Original bytes clear
342 * the flag. The query is serialized with enable/disable through the per-hook call gate because
343 * the backend flag is not atomic. This query never repairs drift. A later toggle reconciles only
344 * an attributable opposite witness. Foreign or indeterminate bytes preserve the published state
345 * and refuse the toggle.
346 * @note Setup/control-plane only: may wait for an in-flight guarded call or hook state transition.
347 */
348 [[nodiscard]] bool is_enabled() const noexcept;
349
350 /**
351 * @brief Returns the typed original-function trampoline (inline hooks only); the UNGUARDED fast path.
352 * @tparam Fn The full function-pointer type of the original (e.g. `void(*)(void*)`).
353 * @return A trampoline of type Fn, or nullptr for a mid hook, a disengaged handle, or a backend miss.
354 * @details This is the common process-lifetime game-detour path: `h.original<fn>()(args...)` is one
355 * indirection and takes no lock, so the caller MUST guarantee the hook outlives the call. For the
356 * opt-in guarded form used when teardown may race an in-flight call, use @ref call. Mid hooks
357 * have no callable original, so original<Fn>() is nullptr for them.
358 * @note Callback-safe: one indirection, no lock, no allocation; the caller owns the hook-outlives-the-call
359 * guarantee.
360 */
361 140 template <detail::FunctionPointer Fn> [[nodiscard]] Fn original() const noexcept
362 {
363 140 return reinterpret_cast<Fn>(original_address());
364 }
365
366 /**
367 * @brief Calls the original function through the trampoline under DMK's per-hook guard (inline hooks only).
368 * @tparam Ret The original's return type (defaults to void).
369 * @tparam Args The exact parameter types for the original by-value C ABI.
370 * A movable argument moves into the dispatch. If its ABI type cannot construct from an rvalue,
371 * dispatch preserves the prior lvalue copy path.
372 * @return The original's return value, or a value-initialized Ret when the hook is inactive / not inline.
373 * @details Pins the refcounted call gate before taking its recursive mutex and holds both through the
374 * invocation. Teardown publishes a null trampoline under the same mutex before destroying any
375 * reclaimable backend storage, so a late call fails closed and an in-flight call drains first. Use
376 * @ref original when the hook lifetime is already guaranteed and this guard is unnecessary.
377 *
378 * The Hook object's storage must outlive this member call, although teardown work can race it.
379 * The caller must supply the original function's exact parameter types, because a deduced
380 * reference reconstructs the wrong function-pointer ABI. This guard does not drain a thread that
381 * entered the original by another path.
382 * @note Callback-safe: the atomic `shared_ptr` gate snapshot uses a bounded internal lock, and `call`
383 * performs no allocation or I/O before dispatch.
384 * @warning `call` holds the per-hook recursive gate mutex across the dispatch, so concurrent calls
385 * through one handle serialize, and a second thread blocks for the first call's full duration.
386 * Two threads through one handle measured lower aggregate throughput than one thread
387 * (`docs/analysis/hot_path_bench_v4/`). For a hot target called from several threads, use
388 * @ref original.
389 */
390 17 template <typename Ret = void, typename... Args> Ret call(Args... args) const
391 {
392 // GuardedDispatch pins the gate and holds its lock through this invocation.
393 17 const GuardedDispatch dispatch{*this};
394
5/8
int DetourModKit::hook::Hook::call<int, (anonymous namespace)::CopyOnlyInt>((anonymous namespace)::CopyOnlyInt) const:
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1 time.
int DetourModKit::hook::Hook::call<int, std::unique_ptr<int, std::default_delete<int> > >(std::unique_ptr<int, std::default_delete<int> >) const:
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1 time.
int DetourModKit::hook::Hook::call<int, int>(int) const:
✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 5 taken 10 times.
int DetourModKit::hook::Hook::call<int, int, int>(int, int) const:
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1 time.
17 if (dispatch.trampoline == nullptr)
395 {
396 if constexpr (!std::is_void_v<Ret>)
397 {
398 4 return Ret{};
399 }
400 else
401 {
402 return;
403 }
404 }
405
4/8
int DetourModKit::hook::Hook::call<int, (anonymous namespace)::CopyOnlyInt>((anonymous namespace)::CopyOnlyInt) const:
✓ Branch 6 → 7 taken 1 time.
✗ Branch 6 → 11 not taken.
int DetourModKit::hook::Hook::call<int, std::unique_ptr<int, std::default_delete<int> > >(std::unique_ptr<int, std::default_delete<int> >) const:
✓ Branch 7 → 8 taken 1 time.
✗ Branch 7 → 14 not taken.
int DetourModKit::hook::Hook::call<int, int>(int) const:
✓ Branch 6 → 7 taken 10 times.
✗ Branch 6 → 11 not taken.
int DetourModKit::hook::Hook::call<int, int, int>(int, int) const:
✓ Branch 7 → 8 taken 1 time.
✗ Branch 7 → 12 not taken.
13 return reinterpret_cast<Ret (*)(Args...)>(dispatch.trampoline)(forward_call_argument<Args>(args)...);
406 17 }
407
408 /**
409 * @brief The fail-closed-distinguishing sibling of @ref call: dispatches through the original and reports
410 * whether the guarded gate actually let the call through.
411 * @tparam Ret The original's return type (default void), reconstructed by value as in @ref call.
412 * @tparam Args The original's exact by-value parameter types; see @ref call.
413 * @return The original's return value, or InvalidHookState when the guarded gate refuses dispatch.
414 * @details Uses the same lifetime guard as @ref call but preserves a suppressed call in the error channel,
415 * which distinguishes it from a legitimate value-initialized result. `try_call<void>()` reports
416 * whether dispatch occurred.
417 * @note Callback-safe on the same terms as @ref call: the same two locks, and no allocation or I/O before
418 * dispatch.
419 */
420 32 template <typename Ret = void, typename... Args> [[nodiscard]] Result<Ret> try_call(Args... args) const
421 {
422 32 const GuardedDispatch dispatch{*this};
423
10/12
std::expected<int, DetourModKit::Error> DetourModKit::hook::Hook::try_call<int>() const:
✓ Branch 3 → 4 taken 13 times.
✓ Branch 3 → 7 taken 10 times.
std::expected<int, DetourModKit::Error> DetourModKit::hook::Hook::try_call<int, (anonymous namespace)::CopyOnlyInt>((anonymous namespace)::CopyOnlyInt) const:
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 7 taken 1 time.
std::expected<int, DetourModKit::Error> DetourModKit::hook::Hook::try_call<int, std::unique_ptr<int, std::default_delete<int> > >(std::unique_ptr<int, std::default_delete<int> >) const:
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 7 taken 1 time.
std::expected<int, DetourModKit::Error> DetourModKit::hook::Hook::try_call<int, int>(int) const:
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 7 taken 1 time.
std::expected<unsigned long, DetourModKit::Error> DetourModKit::hook::Hook::try_call<unsigned long, unsigned long, _XINPUT_STATE*>(unsigned long, _XINPUT_STATE*) const:
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 7 taken 1 time.
std::expected<void, DetourModKit::Error> DetourModKit::hook::Hook::try_call<void, int>(int) const:
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 7 taken 1 time.
32 if (dispatch.trampoline == nullptr)
424 {
425 17 return std::unexpected(Error{ErrorCode::InvalidHookState, "hook::try_call"});
426 }
427 if constexpr (std::is_void_v<Ret>)
428 {
429
1/2
✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 13 not taken.
1 reinterpret_cast<void (*)(Args...)>(dispatch.trampoline)(forward_call_argument<Args>(args)...);
430 1 return {};
431 }
432 else
433 {
434
5/10
std::expected<int, DetourModKit::Error> DetourModKit::hook::Hook::try_call<int>() const:
✓ Branch 7 → 8 taken 10 times.
✗ Branch 7 → 13 not taken.
std::expected<int, DetourModKit::Error> DetourModKit::hook::Hook::try_call<int, (anonymous namespace)::CopyOnlyInt>((anonymous namespace)::CopyOnlyInt) const:
✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 14 not taken.
std::expected<int, DetourModKit::Error> DetourModKit::hook::Hook::try_call<int, std::unique_ptr<int, std::default_delete<int> > >(std::unique_ptr<int, std::default_delete<int> >) const:
✓ Branch 9 → 10 taken 1 time.
✗ Branch 9 → 16 not taken.
std::expected<int, DetourModKit::Error> DetourModKit::hook::Hook::try_call<int, int>(int) const:
✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 14 not taken.
std::expected<unsigned long, DetourModKit::Error> DetourModKit::hook::Hook::try_call<unsigned long, unsigned long, _XINPUT_STATE*>(unsigned long, _XINPUT_STATE*) const:
✓ Branch 9 → 10 taken 1 time.
✗ Branch 9 → 15 not taken.
28 return reinterpret_cast<Ret (*)(Args...)>(dispatch.trampoline)(
435 3 forward_call_argument<Args>(args)...
436 14 );
437 }
438 32 }
439
440 /**
441 * @brief Arms the hook: patches the target so the detour begins running.
442 * @return Success if the hook is now active (or already was and is the target's newest live layer). On
443 * failure the Error carries the reason (LoaderLockActive, LayerConflict, BackendFailed,
444 * EnableFailed, DisableFailed, InvalidHookState). LayerConflict changes neither the target bytes
445 * nor the hook's state. An already-armed lower layer stays armed. EnableFailed means this call
446 * published no new arm. An uncommitted or rolled-back arm from Disabled leaves Disabled. A
447 * pre-write refusal preserves the prior published state and target bytes. BackendFailed means the
448 * hook IS active and routes calls. @ref is_enabled reports true, and an inline hook's @ref call
449 * works. The backend's patch transaction reported an error after the patch commit. Target page
450 * protection can remain unrestored. DisableFailed means this call did not prove a disarm after a
451 * rejected or uncertain arm. The handle remains conservatively active. The caller must quiesce or
452 * disable it before teardown.
453 * @details The operation is idempotent and thread-safe without external synchronization. An already-active
454 * hook succeeds only while its own patch is what the target holds. Original bytes under an active
455 * state prove that a third party disarmed the target. The call reconciles state, then uses the
456 * ordinary arm path. Publish everything the detour needs before this call. `[B-83]` owns that
457 * rule.
458 * @details `[B-97]` decides the reported state from the target's bytes, not from the backend result. Only
459 * the saved prologue authorizes Disabled and only the exact committed patch authorizes Active,
460 * so an ambiguous witness stays conservatively Active. This noexcept boundary contains backend
461 * exceptions and reconciles them the same way.
462 * @note The toggle refuses bytes that belong to neither this hook nor its saved prologue. It does not
463 * overwrite them because the backend emits its jmp over present bytes. The refusal is EnableFailed
464 * and writes nothing, so a caller that resolves the conflict can retry. An unreadable target gets
465 * the same refusal.
466 * @note Only the newest live hook on a target can arm it. An arm from underneath a newer layer is refused
467 * with LayerConflict and nothing is written. The layer check precedes the idempotency check, so an
468 * already-armed lower layer also gets LayerConflict instead of the no-op Success it gets on top. To
469 * stack detours, arm the base hook before you create the one above it. A hook created while the
470 * layer below is armed captures the patched prologue and resumes into it.
471 * @note Setup/control-plane only: arming patches the target and serializes on the per-hook call gate.
472 */
473 [[nodiscard]] Result<void> enable() noexcept;
474
475 /**
476 * @brief Disarms the hook without destroying it.
477 * @return Success if the hook is now disabled (or already was and is the target's newest live layer). On
478 * failure the Error carries the reason (LoaderLockActive, LayerConflict, BackendFailed,
479 * DisableFailed, InvalidHookState). A live lower layer remains armed after LayerConflict and
480 * truthfully reports @ref is_enabled. DisableFailed means this call did not prove a disarm. A
481 * failed transition from Active stays Active. A pre-write refusal from Disabled preserves Disabled
482 * and the target bytes.
483 * BackendFailed means the disarm DID take effect. @ref is_enabled reports false, and the target no
484 * longer redirects. The backend's restore transaction reported an error after the disarm. Target
485 * page protection can remain unrestored.
486 * @details As in @ref enable, the target's bytes decide under `[B-97]`. Disabled publishes once the saved
487 * prologue reads back, even after a backend failure or throw that follows the committed restore.
488 * An ambiguous witness leaves the hook active, so a retry can disarm after the caller restores
489 * this hook's exact patch bytes. An already-disabled hook succeeds only while the saved prologue
490 * is what the target holds. The call reconciles a disabled state over this hook's own patch, then
491 * retries the ordinary restore.
492 * @note The call refuses Foreign or unreadable bytes exactly as in @ref enable. It returns DisableFailed
493 * and writes nothing. Teardown applies the same rule and pins the backend instead of a restore. See
494 * @ref Hook::~Hook.
495 * @note Only the newest live hook on a target can disarm it, for the reason @ref enable gives: this hook's
496 * saved prologue predates a newer layer's patch. Tear down or disable the newer layer first.
497 * @note Setup/control-plane only: disarming restores target bytes and serializes on the per-hook call
498 * gate.
499 */
500 [[nodiscard]] Result<void> disable() noexcept;
501
502 /**
503 * @brief Detaches the hook from this handle, retaining its backend for the process lifetime.
504 * @details The handle becomes disengaged (operator bool is then false and ~Hook is a no-op). An armed hook
505 * stays patched and dispatching; a disabled hook stays disabled, but its backend and ledger record
506 * are still intentionally retained. This is the explicit "install once, never unhook" pattern;
507 * it is not an error path.
508 * @note Booked by @ref diagnostics::total_intentional_leaks like a defensive pin, and the target stays
509 * recorded: @ref is_target_hooked keeps reporting it hooked, a strict install keeps being refused,
510 * and a layer installed underneath this one can no longer enable, disable, or restore. Every
511 * byte-writing operation that layer attempts is refused with @ref ErrorCode::LayerConflict for the
512 * process lifetime. A layer installed AFTER it still tears down normally.
513 * @note Setup/control-plane only: transfers the backend to process-lifetime retention; do not call from a
514 * hook or input callback.
515 * @warning The detour and everything it reaches must remain mapped for the rest of the process.
516 */
517 void release() noexcept;
518
519 private:
520 struct Impl;
521
522 template <typename Arg>
523 20 [[nodiscard]] static decltype(auto) forward_call_argument(std::remove_reference_t<Arg> &arg) noexcept
524 {
525 if constexpr (!std::is_reference_v<Arg> && !std::is_move_constructible_v<Arg>)
526 {
527 2 return (arg);
528 }
529 else
530 {
531 18 return std::forward<Arg>(arg);
532 }
533 }
534
535 /**
536 * @brief Refcounted call guard defined in src/internal/hook_backend.hpp.
537 * @details A late @ref call pins it before locking, so concurrent teardown cannot free its mutex.
538 */
539 struct CallGate;
540 Hook(std::unique_ptr<Impl> impl, std::shared_ptr<CallGate> gate) noexcept;
541
542 /// Raw inline trampoline (or nullptr); the UNGUARDED backend touch behind original<Fn>(). Defined in .cpp.
543 [[nodiscard]] void *original_address() const noexcept;
544
545 /// Copies the atomic call-gate reference into a strong local for @ref call to pin. Defined in .cpp.
546 [[nodiscard]] std::shared_ptr<CallGate> pin_call_gate() const noexcept;
547
548 /**
549 * @brief Locks the gate's recursive_mutex and returns the owning token; the @ref call guard. Defined in
550 * .cpp.
551 * @details noexcept: a recursive_mutex::lock failure yields an unowned lock (the caller checks
552 * owns_lock()) rather than throwing out of the non-noexcept @ref call.
553 */
554 [[nodiscard]] std::unique_lock<std::recursive_mutex> acquire_call_lock(CallGate *gate) const noexcept;
555
556 /// The gate's published callable trampoline (nullptr when inactive); read with the call lock held.
557 [[nodiscard]] void *active_trampoline(CallGate *gate) const noexcept;
558
559 /**
560 * @brief One entry through the call gate, shared verbatim by @ref call and @ref try_call.
561 * @details Pins the gate, locks it, and snapshots its trampoline. Any failed stage leaves @ref trampoline
562 * null. Retaining the gate and lock prevents teardown from reclaiming an in-flight trampoline.
563 */
564 struct GuardedDispatch
565 {
566 49 explicit GuardedDispatch(const Hook &hook)
567 49 {
568 49 gate = hook.pin_call_gate();
569
2/2
✓ Branch 8 → 9 taken 3 times.
✓ Branch 8 → 10 taken 46 times.
49 if (!gate)
570 3 return;
571 46 guard = hook.acquire_call_lock(gate.get());
572
1/2
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 46 times.
46 if (!guard.owns_lock())
573 return;
574 46 trampoline = hook.active_trampoline(gate.get());
575 }
576
577 std::shared_ptr<CallGate> gate;
578 std::unique_lock<std::recursive_mutex> guard;
579 /// The live trampoline to dispatch through, or nullptr when any gate stage failed closed.
580 void *trampoline = nullptr;
581 };
582
583 std::unique_ptr<Impl> m_impl;
584 /**
585 * @brief The shared call gate, held atomically so @ref call can pin it without racing a concurrent
586 * teardown/move that publishes over it.
587 */
588 std::atomic<std::shared_ptr<CallGate>> m_gate;
589
590 friend Result<Hook> mid_at(MidRequest request, MidHookFn detour);
591 friend Result<Hook> detail::inline_at_raw(InlineRequest request, void *detour);
592 };
593
594 /**
595 * @class HookStack
596 * @brief Move-only owner of a set of Hook handles that guarantees newest-first (LIFO) teardown.
597 * @details `[B-16]` Same-target layers must unwind newest-first, or the older layer's restore clobbers a
598 * prologue the newer layer's live trampoline still chains through. A bare `std::vector<Hook>` has
599 * unspecified element destruction order. This container restores back-to-front instead. Prefer it
600 * over a bare vector when hooks stay alive together. This rule especially applies to hooks layered on
601 * one address and successes returned by @ref install_all.
602 * Push those successes in table order. Inline/mid @ref Hook handles only: @ref VmtHook already
603 * unwinds its objects newest-first.
604 * @note Move-only, mirroring @ref Hook. Not internally synchronized: build and tear it down on the setup
605 * thread, exactly like the hooks it holds.
606 */
607 class HookStack
608 {
609 public:
610 /**
611 * @brief Constructs an empty hook stack.
612 * @note Setup/control-plane only: build hook ownership during init/shutdown or worker setup, not from a
613 * hook callback.
614 */
615 11 HookStack() noexcept = default;
616
617 /**
618 * @brief Move-constructs by adopting @p other's hooks without tearing them down.
619 * @note Setup/control-plane only: moving a stack transfers ownership and is not internally synchronized
620 * with concurrent reads or teardown.
621 */
622 2 HookStack(HookStack &&other) noexcept : m_hooks(std::move(other.m_hooks)) { other.m_hooks.clear(); }
623
624 /**
625 * @brief Move-assigns by tearing down this stack's current hooks newest-first, then adopting @p other's.
626 * @details Deliberately not defaulted: a defaulted move-assignment destroys the replaced hooks in a
627 * container-defined order. The moved-from source is cleared, so empty() remains a stable
628 * post-move query.
629 * @note Setup/control-plane only: move-assignment may restore existing hooks and is not synchronized with
630 * hook callbacks or concurrent stack access.
631 */
632 1 HookStack &operator=(HookStack &&other) noexcept
633 {
634
1/2
✓ Branch 2 → 3 taken 1 time.
✗ Branch 2 → 8 not taken.
1 if (this != &other)
635 {
636 1 teardown_newest_first();
637 2 m_hooks = std::move(other.m_hooks);
638 1 other.m_hooks.clear();
639 }
640 1 return *this;
641 }
642
643 HookStack(const HookStack &) = delete;
644 HookStack &operator=(const HookStack &) = delete;
645
646 /**
647 * @brief Restores every owned hook's prologue, newest-first.
648 * @note Setup/control-plane only: destroy the stack after detour entry points and worker calls that might
649 * use its hooks are quiescent.
650 * @note Explicitly noexcept: like @ref Hook::~Hook this can run from DLL_PROCESS_DETACH / loader-lock
651 * teardown, where an escaping exception terminates the host. Every ~Hook it invokes already fails
652 * closed, so no exception escapes.
653 */
654 7 ~HookStack() noexcept { teardown_newest_first(); }
655
656 /**
657 * @brief Moves @p hook onto the top of the stack and returns a reference to the stored handle.
658 * @return A reference to the just-stored @ref Hook, valid until the next @ref push / @ref clear / move.
659 * Use it to capture the trampoline immediately after a push, for example
660 * `stack.push(std::move(h)).original<Fn>()`.
661 * @details Push order IS layer order: push the base hook first. If storage growth throws `std::bad_alloc`,
662 * the stack unwinds @p hook (which restores its prologue) and leaves the stored hooks
663 * intact.
664 * @note Setup/control-plane only: may allocate and may publish a new hook owner. Do not call from a hook
665 * callback.
666 */
667 123 Hook &push(Hook hook)
668 {
669 246 m_hooks.push_back(std::move(hook));
670 123 return m_hooks.back();
671 }
672
673 /**
674 * @brief Reserves storage for @p capacity hooks so a batch of @ref push calls does not reallocate.
675 * @note Setup/control-plane only: may allocate.
676 */
677 void reserve(std::size_t capacity) { m_hooks.reserve(capacity); }
678
679 /**
680 * @brief Returns the number of hooks currently owned.
681 * @note Callback-safe: non-blocking and non-allocating when no thread mutates or destroys this stack
682 * concurrently.
683 */
684 9 [[nodiscard]] std::size_t size() const noexcept { return m_hooks.size(); }
685
686 /**
687 * @brief Reports whether the stack owns no hooks.
688 * @note Callback-safe: non-blocking and non-allocating when no thread mutates or destroys this stack
689 * concurrently.
690 */
691 6 [[nodiscard]] bool empty() const noexcept { return m_hooks.empty(); }
692
693 /**
694 * @brief Tears down every owned hook newest-first, leaving the stack empty and retaining capacity.
695 * @note Setup/control-plane only: restores hooks and is not synchronized with callbacks or concurrent
696 * stack access.
697 */
698 125 void clear() noexcept { teardown_newest_first(); }
699
700 private:
701 /// Restores the owned hooks strictly back-to-front (newest layer first): pop_back destroys the newest.
702 133 void teardown_newest_first() noexcept
703 {
704
2/2
✓ Branch 5 → 3 taken 122 times.
✓ Branch 5 → 6 taken 133 times.
255 while (!m_hooks.empty())
705 {
706 122 m_hooks.pop_back();
707 }
708 133 }
709
710 std::vector<Hook> m_hooks;
711 };
712
713 /**
714 * @brief Installs a DISABLED inline hook at the request's target; call @ref Hook::enable to arm it.
715 * @tparam Fn The detour's function type. The function-to-void* cast happens here, behind a word-size
716 * static_assert.
717 * @param request Name, target (absolute or deferred scan), and policy.
718 * @param detour Pointer to the detour function.
719 * @return The RAII @ref Hook on success, with the target unpatched, or an Error.
720 * @details This call builds the trampoline and validates the target, so it reports an install failure here.
721 * Only the arming is deferred. Publish the returned handle where the detour can reach it, then
722 * enable.
723 * @warning The detour MUST NOT THROW. The patched target calls it directly, so an escaping exception unwinds
724 * through a caller that never expected one and terminates the host. The type does not enforce this,
725 * because `Fn *` accepts an ordinary function pointer.
726 * @warning Quiescence before teardown is CALLER-OWNED. An inline detour replaces the target and runs with DMK
727 * nowhere in the call path, so DMK cannot know whether a thread is still inside the detour and
728 * cannot wait for one. Prove that no thread can execute the detour before the handle dies.
729 * @ref mid_at owns this instead of the caller.
730 * @warning When the detour lives in a Logic DLL, that ownership extends to the unload. The required order is
731 * stop every thread that can reach the target, JOIN them, destroy the handle, and only then unmap
732 * the provider. Destroying the handle first leaves a thread inside a detour whose prologue is being
733 * restored; unmapping first leaves it executing freed pages. Neither is detectable from here.
734 * @note Setup/control-plane only: the install allocates the trampoline and validates the target.
735 */
736 332 template <class Fn> [[nodiscard]] Result<Hook> inline_at(InlineRequest request, Fn *detour)
737 {
738 static_assert(sizeof(Fn *) == sizeof(void *), "function pointer must be word-sized");
739
7/14
std::expected<DetourModKit::hook::Hook, DetourModKit::Error> DetourModKit::hook::inline_at<int ((anonymous namespace)::CopyOnlyInt) noexcept>(DetourModKit::hook::InlineRequest, int (*)((anonymous namespace)::CopyOnlyInt) noexcept):
✓ Branch 5 → 6 taken 2 times.
✗ Branch 5 → 10 not taken.
std::expected<DetourModKit::hook::Hook, DetourModKit::Error> DetourModKit::hook::inline_at<int (std::unique_ptr<int, std::default_delete<int> >) noexcept>(DetourModKit::hook::InlineRequest, int (*)(std::unique_ptr<int, std::default_delete<int> >) noexcept):
✓ Branch 5 → 6 taken 2 times.
✗ Branch 5 → 10 not taken.
std::expected<DetourModKit::hook::Hook, DetourModKit::Error> DetourModKit::hook::inline_at<int (int, int) noexcept>(DetourModKit::hook::InlineRequest, int (*)(int, int) noexcept):
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 10 not taken.
std::expected<DetourModKit::hook::Hook, DetourModKit::Error> DetourModKit::hook::inline_at<unsigned long (unsigned long, _XINPUT_STATE*) noexcept>(DetourModKit::hook::InlineRequest, unsigned long (*)(unsigned long, _XINPUT_STATE*) noexcept):
✓ Branch 5 → 6 taken 3 times.
✗ Branch 5 → 10 not taken.
std::expected<DetourModKit::hook::Hook, DetourModKit::Error> DetourModKit::hook::inline_at<int (int)>(DetourModKit::hook::InlineRequest, int (*)(int)):
✓ Branch 5 → 6 taken 58 times.
✗ Branch 5 → 10 not taken.
std::expected<DetourModKit::hook::Hook, DetourModKit::Error> DetourModKit::hook::inline_at<int (int, int)>(DetourModKit::hook::InlineRequest, int (*)(int, int)):
✓ Branch 5 → 6 taken 189 times.
✗ Branch 5 → 10 not taken.
std::expected<DetourModKit::hook::Hook, DetourModKit::Error> DetourModKit::hook::inline_at<void ()>(DetourModKit::hook::InlineRequest, void (*)()):
✓ Branch 5 → 6 taken 77 times.
✗ Branch 5 → 10 not taken.
332 return detail::inline_at_raw(std::move(request), reinterpret_cast<void *>(detour));
740 }
741
742 /**
743 * @brief Installs a DISABLED mid-function hook at the request's target; call @ref Hook::enable to arm it.
744 * @param request Name, target (absolute or deferred scan), and policy.
745 * @param detour The DMK-typed mid-hook detour (keeps its MidHookFn type; no raw cast at the call site).
746 * @return The RAII @ref Hook on success, with the target unpatched, or an Error.
747 * `ErrorCode::MidHookCapacityExhausted` means every mid-hook adapter is in use and nothing was patched.
748 * @details See @ref inline_at for the two-step install transaction; it applies identically here.
749 *
750 * Unlike @ref inline_at, DMK reaches a mid-hook callback through its own adapter, so DMK owns
751 * exception containment and ordinary off-loader-lock rundown. Tombstoning is unconditional: no
752 * callback begins after ~Hook returns. A wait is not unconditional. Off the loader lock and outside
753 * the callback, destruction also waits out every admitted callback. An entrant that the adapter
754 * could not record cannot be ruled out as the destroying thread, so that case pins instead.
755 * @note A mid hook holds one adapter from a fixed pool for its lifetime. A clean teardown returns the
756 * adapter. A teardown that pins the backend instead (see @ref Hook::~Hook), and a hook retained by
757 * @ref Hook::release, keep theirs for the process lifetime, because the stub stays reachable. Loader-
758 * lock teardown and destruction from inside the callback both pin by design, so a host that does
759 * either at scale spends pool capacity permanently.
760 * @note On x64, first publication also commits a permanently retained routed gateway and inline trampoline.
761 * The backend reserves their bounded logical and allocator-block capacity before the hook can publish.
762 * Clean destruction restores the target but does not reclaim that routed chain.
763 * @note After ordinary off-loader-lock destruction returns, a pinned backend that remains patched is inert:
764 * its live recheck refuses later callbacks, and @ref is_target_hooked stays true for the patched target.
765 * @warning Every teardown that pins (loader lock, self-destruction, or an unrecordable entrant) tombstones but
766 * does not wait, so the callback provider must remain mapped until the admitted callback returns.
767 * @ref Hook::release bypasses tombstoning and keeps dispatching for the process lifetime.
768 * @note Setup/control-plane only: the install claims an adapter and builds the routed chain.
769 */
770 [[nodiscard]] Result<Hook> mid_at(MidRequest request, MidHookFn detour);
771
772 struct InstallOutcome;
773
774 /// Internal tag carrying the one audited function-to-void* cast for a declarative inline @ref HookSpec.
775 struct InlineDetour
776 {
777 void *fn = nullptr;
778 };
779
780 /**
781 * @class HookSpec
782 * @brief One row of a declarative install table consumed by @ref install_all.
783 * @details The factories are the SOLE constructor, so a forgotten name or target is a COMPILE error. The
784 * detour uses a typed variant: an @ref InlineDetour from the inline_hook factory's one audited cast,
785 * or a typed MidHookFn. A table author therefore never writes a reinterpret_cast. Each row carries
786 * @ref Options, which @ref install_all applies verbatim. One row can request @ref Prologue::Relocate
787 * or fail_if_already_hooked while its neighbours keep the safe default.
788 */
789 class HookSpec
790 {
791 public:
792 /**
793 * @brief Builds an inline-hook row; performs the single audited function-to-void* cast.
794 * @tparam Fn The detour's function type (word-size static_assert).
795 * @param name Row name, forwarded to the eventual @ref InlineRequest.
796 * @param target Owned scan request that resolves the hook target.
797 * @param detour Typed inline detour function.
798 * @param severity Mandatory rows abort @ref install_all on failure; best-effort rows report the error and
799 * let later rows continue.
800 * @param options Per-row install policy (@ref Prologue escalation, fail_if_already_hooked). Defaults to the
801 * safe @ref Options default, so an existing table needs no change; set it to give one row a
802 * different policy than the rest without an out-of-band install call.
803 * @return A declarative table row consumed by @ref install_all.
804 * @note Setup/control-plane only: table construction may allocate through @p name and @p target.
805 */
806 template <class Fn>
807 13 [[nodiscard]] static HookSpec inline_hook(
808 std::string name,
809 scan::OwnedScanRequest target,
810 Fn *detour,
811 Severity severity = Severity::Mandatory,
812 Options options = {}
813 )
814 {
815 static_assert(sizeof(Fn *) == sizeof(void *), "function pointer must be word-sized");
816 return HookSpec{
817 13 std::move(name),
818 13 std::move(target),
819 InlineDetour{reinterpret_cast<void *>(detour)},
820 severity,
821 options
822 26 };
823 }
824
825 /**
826 * @brief Builds a mid-hook row; the @ref MidHookFn stays typed, with no raw cast at the call site.
827 * @param name Row name, forwarded to the eventual @ref MidRequest.
828 * @param target Owned scan request that resolves the hook target.
829 * @param detour Typed mid-hook detour.
830 * @param severity Mandatory rows abort @ref install_all on failure; best-effort rows report the error and
831 * let later rows continue.
832 * @param options Per-row install policy applied by @ref install_all.
833 * @return A declarative table row consumed by @ref install_all.
834 * @note Setup/control-plane only: table construction may allocate through @p name and @p target.
835 */
836 [[nodiscard]] static HookSpec mid_hook(
837 std::string name,
838 scan::OwnedScanRequest target,
839 MidHookFn detour,
840 Severity severity = Severity::Mandatory,
841 Options options = {}
842 )
843 {
844 return HookSpec{std::move(name), std::move(target), detour, severity, options};
845 }
846
847 /// Returns the row name forwarded to the eventual install request.
848 [[nodiscard]] std::string_view name() const noexcept { return m_name; }
849 /// Returns whether this row is mandatory or best-effort.
850 [[nodiscard]] Severity severity() const noexcept { return m_severity; }
851 /// Returns the per-row install policy applied by @ref install_all.
852 [[nodiscard]] const Options &options() const noexcept { return m_options; }
853
854 private:
855 13 HookSpec(
856 std::string name,
857 scan::OwnedScanRequest target,
858 std::variant<InlineDetour, MidHookFn> detour,
859 Severity severity,
860 Options options
861 ) noexcept
862 39 : m_name(std::move(name)), m_target(std::move(target)), m_detour(std::move(detour)),
863 13 m_severity(severity), m_options(options)
864 {
865 13 }
866
867 std::string m_name;
868 scan::OwnedScanRequest m_target;
869 /// Inline vs mid is encoded by the active alternative.
870 std::variant<InlineDetour, MidHookFn> m_detour;
871 Severity m_severity;
872 /// Per-row install policy applied verbatim by @ref install_all.
873 Options m_options;
874
875 friend Result<std::vector<InstallOutcome>> install_all(std::span<const HookSpec> table) noexcept;
876 };
877
878 /**
879 * @struct InstallOutcome
880 * @brief Per-row result of @ref install_all, in table order, so a mod can correlate which optional hooks
881 * landed.
882 * @warning A `std::vector<InstallOutcome>` has unspecified element destruction order. That order cannot prove
883 * newest-first teardown for hooks layered on one target (`[B-16]`, see @ref HookStack). Move
884 * successful hooks into a @ref HookStack in table order for clean teardown.
885 */
886 struct InstallOutcome
887 {
888 std::string name;
889 Severity severity;
890 /// The installed Hook on success; an Error (e.g. NoMatch) when the row was skipped.
891 Result<Hook> hook;
892 };
893
894 /**
895 * @brief Installs a whole declarative table of DISABLED hooks, returning one outcome per row.
896 * @param table The spec rows. Taken as a const span so a `const k_hook_table` binds; install_all copies each
897 * OwnedScanRequest it needs and never moves out of the caller's table.
898 * @return The per-row outcomes on success, with every successful row unpatched. LoaderLockActive fails before
899 * all rows. The first @ref Severity::Mandatory miss also fails the outer Result.
900 * @details Every row is installed disabled (see @ref inline_at), so a table lands as one unarmed unit: take
901 * ownership of the outcomes, then arm the rows you want by calling @ref Hook::enable on each. Rolling
902 * back a partial table therefore never has to disarm a live hook. noexcept, matching
903 * scan::resolve_batch: it catches bad_alloc / backend failure internally and reports it per row rather
904 * than throwing across the init path.
905 * @warning The returned vector has unspecified element destruction order. See the @ref InstallOutcome
906 * warning. Move successful hooks into a @ref HookStack in table order for newest-first teardown.
907 * @note Setup/control-plane only: a batch install that resolves scans and allocates per row.
908 */
909 [[nodiscard]] Result<std::vector<InstallOutcome>> install_all(std::span<const HookSpec> table) noexcept;
910
911 /**
912 * @brief Reports whether a DMK hook (this kit) currently owns or is installing @p target.
913 * @details Consults this instance's ledger only; it is the exact same-kit query, not the foreign-JMP
914 * heuristic. Hooks installed by other statically-linked DMK consumers in the same process are not
915 * visible. During a concurrent install it may report true after the target is reserved but before the
916 * backend patch is committed; that fail-closed bias prevents a redundant racing install from treating
917 * the target as free. Use it to short-circuit a redundant install; to also catch foreign hooks, set
918 * Options::fail_if_already_hooked on the install instead.
919 * @note Setup/control-plane only: the query takes the ledger's exclusive mutex, which installs and teardowns
920 * contend on.
921 */
922 [[nodiscard]] bool is_target_hooked(Address target) noexcept;
923
924 /**
925 * @struct VmtOptions
926 * @brief Policy for @ref vmt_for and @ref VmtHook::apply_to, symmetric with @ref Options.
927 */
928 struct VmtOptions
929 {
930 /**
931 * @brief Refuse to clone/apply onto an object whose vptr already points at a vtable cloned by this kit.
932 * @details Cloning an object that is already on a clone reads the first clone as if it were the original
933 * vtable, so the first mod's hooked methods get baked into the second's "original" - the silent
934 * double-hook failure mode. Default false preserves the permissive behaviour.
935 */
936 bool fail_if_already_hooked = false;
937
938 /**
939 * @brief Pre-flight-decode the first byte of the original vtable slot and refuse a breakpoint/jump-stub.
940 * @details A 0xCC/0xCD first byte is a breakpoint pad, not a function; a same-module `jmp rel8/rel32` is a
941 * jump stub (e.g. an incremental-link ILT entry). Both are rejected; MSVC adjustor thunks and
942 * real functions pass. Default false. Known false positive: a /INCREMENTAL consumer routes every
943 * function through an ILT stub, which this rejects.
944 */
945 bool fail_on_non_function_pointer = false;
946 };
947
948 class VmtHook;
949
950 /**
951 * @brief Clones the seed object's vtable and swaps the seed onto the clone, returning the owning handle.
952 * @param name A descriptive name for the hook.
953 * @param object The seed object whose vtable is cloned and whose vptr is swapped to the clone.
954 * @param options Create-time policy (fail-if-already-hooked, pre-flight slot decode).
955 * @return The RAII @ref VmtHook on success, or an Error (LoaderLockActive, InvalidArg, InvalidObject,
956 * HookAlreadyExists, BackendFailed, OutOfMemory, SystemCallFailed, or UnknownError). InvalidObject
957 * covers an unreadable, non-writable, or unaligned object word. It also covers an unreadable vtable or
958 * RTTI header prefix. A protection change, unmap, or displaced object word also returns InvalidObject.
959 * @warning Clone during setup or a host-quiesced window. Fault containment does not synchronize virtual
960 * dispatch or make concurrent object destruction safe.
961 * @note Setup/control-plane only: the clone allocates and mutates the seed object's vptr.
962 */
963 [[nodiscard]] Result<VmtHook> vmt_for(std::string name, void *object, VmtOptions options = {});
964
965 /**
966 * @class VmtHook
967 * @brief Move-only RAII handle for a cloned (hooked) vtable applied to one or more live objects.
968 * @details One clone may serve multiple objects; a @ref hook_method affects all of them. VMT hooks have no
969 * enable/disable operation.
970 * @warning The caller must quiesce virtual dispatch across create/apply/remove and keep every applied object
971 * alive through removal. Guarded vptr access is fault containment, not an ownership protocol.
972 * @note Concurrency: object-vptr transitions in @ref vmt_for / @ref apply_to / @ref remove_from / teardown are
973 * serialized by a setup-time object gate so duplicate create/apply checks and swaps are one ordered
974 * operation. @ref original copies the pre-hook slot pointer out under a shared-read lock and returns it,
975 * so TAKING that snapshot is serialised against a concurrent @ref apply_to / @ref hook_method /
976 * @ref remove_method (each takes the matching exclusive write) and never reads a torn mutation. The lock
977 * guards the snapshot, not the call: the returned pointer is then invoked lock-free, so the caller still
978 * owns the hook-outlives-the-call guarantee, exactly as with @ref Hook::original.
979 */
980 class VmtHook
981 {
982 public:
983 VmtHook(VmtHook &&other) noexcept;
984 VmtHook &operator=(VmtHook &&other) noexcept;
985 VmtHook(const VmtHook &) = delete;
986 VmtHook &operator=(const VmtHook &) = delete;
987
988 /**
989 * @brief Restores the original vptr on every applied object, unless released, moved-from, or outranked.
990 * @details A writable object still on this clone is restored to its binding's original vptr.
991 * An object already at that original needs no write and releases the binding safely even when its
992 * word is not writable. Any other or unreadable value retains the dependency because a successor
993 * may still record this clone as the table it will restore. Safely restorable peers are restored,
994 * then an unresolved dependency leaks the clone rather than free a table still in use. The leak
995 * is counted on @ref diagnostics::LeakSubsystem::HookManager and logged with the hook's name.
996 * Destroy VMT hooks newest-first to get the original table back.
997 * @note Explicitly noexcept (a destructor is implicitly noexcept already): like @ref Hook::~Hook it runs
998 * from loader-lock teardown, so the no-throw contract is pinned at the declaration.
999 * @note Setup/control-plane only: teardown restores object vptrs; quiesce virtual dispatch first.
1000 */
1001 ~VmtHook() noexcept;
1002
1003 /// True while this handle owns a live cloned vtable (false after a move-out or @ref release).
1004 [[nodiscard]] explicit operator bool() const noexcept;
1005
1006 /// The hook's registered name (empty for a moved-from / released handle).
1007 [[nodiscard]] std::string_view name() const noexcept;
1008
1009 /**
1010 * @brief Applies the cloned vtable to an additional live object, swapping its vptr.
1011 * @param object The object to put on the clone.
1012 * @param options Apply-time policy (fail-if-already-hooked, pre-flight slot decode).
1013 * @return Success, or an Error (LoaderLockActive, InvalidHookState, InvalidObject, HookAlreadyExists,
1014 * OutOfMemory, or UnknownError). InvalidObject covers an unreadable, non-writable, or unaligned
1015 * object word. It also covers a protection change, displacement, or unmap before publication.
1016 * HookAlreadyExists is likewise returned under every @p options value when this
1017 * handle cannot name what it would displace: @p object already carries this clone but was never
1018 * applied here, or @p object has since moved off the vptr this handle recorded for it (usually a
1019 * newer @ref VmtHook layered on it). Re-applying either would leave teardown restoring a vptr
1020 * @p object never had. Applying an object this handle already tracks and already published is a
1021 * success no-op.
1022 * @warning Apply only while @p object is host-quiesced; the atomic vptr update does not synchronize
1023 * dispatch.
1024 * @note Setup/control-plane only: the apply mutates @p object's vptr under the exclusive object gate.
1025 */
1026 [[nodiscard]] Result<void> apply_to(void *object, VmtOptions options = {});
1027
1028 /**
1029 * @brief Restores the original vptr on one applied object.
1030 * @param object The object to restore.
1031 * @return Success, or LoaderLockActive for a loader-lock caller / InvalidObject for a null @p object /
1032 * InvalidHookState for a disengaged handle / UnknownError when the exclusive object gate could not
1033 * be acquired.
1034 * @details Success does not assert that @p object was applied here, nor that a restore happened: an
1035 * untracked object is a harmless no-op, and a tracked one releases its binding only once its word
1036 * is observed at the recorded original. A writable object on this clone is swapped back to that
1037 * original unless a protection change or unmap defeats the swap. An object already at the original
1038 * needs no write and releases the binding even when its word is not writable. Any other or
1039 * unreadable value is left unchanged and retains the dependency, so teardown can restore it if it
1040 * returns to this clone or leak the clone rather than free a table a successor may still restore.
1041 * @warning Quiesce @p object before restoring it; fault containment does not drain in-flight dispatch.
1042 * @note Setup/control-plane only: the restore mutates @p object's vptr under the exclusive object gate.
1043 */
1044 [[nodiscard]] Result<void> remove_from(void *object);
1045
1046 /**
1047 * @brief Redirects the virtual method at vtable @p index to @p detour in this handle's cloned vtable.
1048 * @tparam Fn The detour's function-pointer type; the function-to-void* cast happens here, once, behind a
1049 * word-size static_assert, so the call site never writes a reinterpret_cast.
1050 * @param index The zero-based vtable index of the method to hook. Count only virtual functions: the
1051 * ABI-specific vtable header (the Itanium offset-to-top + RTTI pointers, or the MSVC RTTI locator) is not
1052 * part of the index. Index 0 is the first virtual method as declared.
1053 * @param detour The replacement function, installed straight into a vtable slot. Its ABI must match the
1054 * original virtual method's true signature. The object pointer arrives as the first integer argument
1055 * (`this` in rcx under the Win64 ABI), followed by the declared parameters. hook_method cannot validate
1056 * that signature. A mismatch is silent ABI corruption.
1057 * @return Success, or an Error: LoaderLockActive (loader-lock caller), InvalidHookState (disengaged
1058 * handle), InvalidArg (null @p detour or an out-of-range @p index), MethodAlreadyHooked (occupied index),
1059 * BackendFailed, or OutOfMemory.
1060 * @warning The detour MUST NOT THROW. The slot holds it directly.
1061 * No DMK frame can contain an exception. An exception crosses a caller that expects none and
1062 * terminates the host. The type cannot enforce this rule because the slot accepts an ordinary
1063 * function pointer. `[B-84]` owns the rule.
1064 * @note Setup/control-plane only: mutates the clone under the exclusive write lock.
1065 * Do not call it from a hooked method's detour while another thread reads the same handle. Install
1066 * all method hooks during setup.
1067 */
1068 42 template <detail::FunctionPointer Fn> [[nodiscard]] Result<void> hook_method(std::size_t index, Fn detour)
1069 {
1070 static_assert(sizeof(Fn) == sizeof(void *), "function pointer must be word-sized");
1071 42 return hook_method_raw(index, reinterpret_cast<void *>(detour));
1072 }
1073
1074 /**
1075 * @brief Returns the pre-hook function pointer for the method at vtable @p index, typed as Fn.
1076 * @tparam Fn The full function-pointer type of the original method, including the leading object pointer
1077 * as the first parameter (the Win64 ABI passes it in rcx).
1078 * @param index The zero-based vtable index used at @ref hook_method time.
1079 * @return A function pointer of type Fn to the original method's slot, or nullptr for an unhooked @p index
1080 * or a disengaged handle.
1081 * @details The per-method analogue of @ref Hook::original: the pre-hook slot value is copied out under a
1082 * shared-read lock, then invoked lock-free through the returned pointer. The slot pointer is fixed for the
1083 * hook's lifetime. The caller only has to keep the hook alive across the call.
1084 * @note Callback-safe on the read side (a shared-lock snapshot copy, no allocation or I/O); the returned
1085 * pointer's invocation is the caller's responsibility.
1086 */
1087 23 template <detail::FunctionPointer Fn> [[nodiscard]] Fn original(std::size_t index) const noexcept
1088 {
1089 23 return reinterpret_cast<Fn>(method_original_address(index));
1090 }
1091
1092 /**
1093 * @brief Lifts the method hook at vtable @p index, restoring the cloned vtable slot to the original.
1094 * @param index The zero-based vtable index previously passed to @ref hook_method.
1095 * @return Success, or an Error: LoaderLockActive (loader-lock caller) / InvalidHookState (disengaged
1096 * handle) / MethodNotFound (@p index is not hooked on this handle).
1097 * @note Setup/control-plane only: rewrites the cloned vtable slot back to the original function pointer
1098 * under the exclusive write lock. This clone-slot restore is a bare pointer write with no thread
1099 * protection against an in-flight dispatch through the slot; quiesce the method before lifting it.
1100 */
1101 [[nodiscard]] Result<void> remove_method(std::size_t index);
1102
1103 /**
1104 * @brief Detaches the cloned vtable for the process lifetime (no vptr is restored; handle disengages).
1105 * @note Booked by @ref diagnostics::total_intentional_leaks, and the clone base stays recorded so
1106 * @ref VmtOptions::fail_if_already_hooked keeps recognising it.
1107 * @note Setup/control-plane only: transfers the clone to process-lifetime retention; do not call from a
1108 * hook or input callback.
1109 * @warning Applied objects continue to use the retained clone.
1110 * Each method detour and its callees must remain mapped until process exit. DMK pins its own
1111 * module, not the detour provider. A detour in a Logic DLL requires that DLL to stay loaded. If
1112 * code unmaps it, the clone slot points at unmapped code.
1113 */
1114 void release() noexcept;
1115
1116 private:
1117 struct Impl;
1118 explicit VmtHook(std::unique_ptr<Impl> impl) noexcept;
1119
1120 /// The non-template method-install primitive behind @ref hook_method; defined in src/hook.cpp.
1121 [[nodiscard]] Result<void> hook_method_raw(std::size_t index, void *detour);
1122
1123 /**
1124 * @brief Snapshots the original slot pointer for @p index under the shared-read lock; the backend touch
1125 * behind @ref original. Returns nullptr for an unhooked index or a disengaged handle. Defined in
1126 * src/hook.cpp.
1127 */
1128 [[nodiscard]] void *method_original_address(std::size_t index) const noexcept;
1129
1130 std::unique_ptr<Impl> m_impl;
1131
1132 friend Result<VmtHook> vmt_for(std::string name, void *object, VmtOptions options);
1133 };
1134 } // namespace hook
1135 } // namespace DetourModKit
1136
1137 #endif // DETOURMODKIT_HOOK_HPP
1138