Cross-platform C++ function hooking library — type-safe, fluent, and honest about its limits.
Status: v1.2.0 · productized from the framework behind an Android game-enhancement system in production. Live-verified on Windows x86_64, Snapdragon 8 Gen 3 and Jetson Orin Nano ARM64, and shipping inside running games on Windows — Direct3D 11 and Direct3D 12 — in GpuThermalGuard's in-game overlay.
Every C-based hooking library makes you re-declare the target's signature by
hand and cast through void*. Get it wrong and you get silent
calling-convention / stack corruption — the worst class of bug in this domain.
Splice makes the compiler enforce it:
#include <splice/splice.h>
// decltype(&eglSwapBuffers) is deduced — your use of `orig` and the args
// must match, or it fails to COMPILE. No void*, no hand-written signatures.
SPLICE_HOOK_LIB("libEGL.so", eglSwapBuffers)
.after([](EGLBoolean /*ret*/, EGLDisplay d, EGLSurface s) { ++g_frames; });
SPLICE_HOOK_ADDR(&add)
.when([]{ return g_enabled.load(); }) // composable runtime gate
.onInvoke([](auto orig, int a, int b) { return orig(a, b) + 100; });
// One-liner diagnostics
SPLICE_TRACE(glDrawArrays); SPLICE_COUNT(malloc); SPLICE_TIME(glTexImage2D);
splice::install_all();That fluent, type-deduced surface is Splice's edge. No C library (ShadowHook, Detours, SubHook) can offer it; the only other modern-C++ option (rcmp) is x86-only and can't unhook. See the comparison.
Pick a target — three ways:
SPLICE_HOOK(some_func); // linker-visible symbol (compile-time &)
SPLICE_HOOK_LIB("libEGL.so", eglSwapBuffers); // resolve by name at install time (dlsym/IAT)
SPLICE_HOOK_ADDR(func_ptr); // any address — vtable slot, RVA, JITChoose what your hook does — three mutually-exclusive verbs:
// .onInvoke — full control. You get `orig`; call it (or don't), rewrite args/return.
SPLICE_HOOK(add).onInvoke([](auto orig, int a, int b) {
return orig(a, b) + 100; // run original, then tweak the result
});
// .before — fire BEFORE the original. No `orig`, no return plumbing.
SPLICE_HOOK(set_user).before([](const char* name) {
audit_log("set_user", name); // observe inputs, original runs as normal
});
// .after — fire AFTER the original. Receives the return value + args.
SPLICE_HOOK(read_bytes).after([](int n_read, void* buf, int cap) {
metrics.add(n_read); // observe the result
});
// (.after on a void function takes just the args: .after([](Args...){...}))Gate when it fires — compose freely with any verb:
SPLICE_HOOK(glDrawArrays)
.when([]{ return g_capture.load(); }) // only while capture is on (else original, zero cost)
.times(120) // ...for the first 120 calls
.before([](GLenum, GLint, GLsizei n) { g_verts += n; });
SPLICE_HOOK(engine_init)
.once() // fire exactly once, then transparent
.after([]{ SPLICE_LOGI("engine up"); });
// .when().once() == .once().when() — order doesn't matter.Hook a C++ member function — this becomes the first explicit arg:
SPLICE_HOOK_MEMBER(Widget::draw) // non-virtual; deduces Ret(Widget*, Args...)
.onInvoke([](auto orig, Widget* self, int layer) {
return orig(self, layer + 1);
});One-liner diagnostics — no lambda needed:
SPLICE_TRACE(eglSwapBuffers); // log each call SPLICE_COUNT(malloc); // call counter
SPLICE_TIME(glTexImage2D); // avg/min/max timingScope-bound auto-disable (RAII) — for tests, plugins, temporary sessions:
{
splice::ScopedHook h = SPLICE_HOOK(target).onInvoke(my_cb);
run_under_hook();
} // h leaves scope → hook disabledCommit:
splice::install_all(); // every hook queued above, in one shot
splice::install_all(splice::live); // ...as ONE coordinated transaction (below)Every signature above is deduced and checked by the compiler — pass the
wrong argument type to orig, or mismatch the callback, and it fails to
build. The C-based libraries make you hand-write the signature and cast
through void*, where the same mistake is silent runtime corruption.
This is the case Splice 1.2.0 was built around, because it is where a hook is hardest to install: the render thread of a game that is already running may be executing the very bytes being replaced.
A COM interface is a pointer to a function table — a vtable. Every
IDXGISwapChain* begins with a pointer to the table shared by its concrete
class, and Present is one slot in it. So there are two different things you
can patch, and <splice/live.h> names both rather than choosing for you.
#include <splice/live.h>
// Direct3D 11: the present path is one method.
splice::Outcome overlay = splice::Vtable<IDXGISwapChain>{swap_chain}
.on(&IDXGISwapChain::Present,
[](auto orig, IDXGISwapChain* self, UINT sync, UINT flags) {
draw_overlay(self);
return orig(self, sync, flags); // `orig` is typed, not a void*
})
.install(splice::live);
if (!overlay.ok()) { /* overlay.state() and overlay.failure() say what happened */ }The pointer-to-member deduces the target as
HRESULT(IDXGISwapChain*, UINT, UINT), and the callback is checked against it:
two-argument .on() is invoke-shaped, so it receives the original first and
then the method's own arguments, this among them explicitly. Get one wrong and
it is a build error — on a COM method, where the alternative is hand-writing
STDMETHODCALLTYPE signatures and casting slot pointers through void*.
.before, .after and the gates are all available per method; .on(pmf) with
no callback starts the chain for them.
Slot resolution is arithmetic on the Itanium ABI, and decoding the dispatch
thunk under MSVC. It refuses rather than guesses for a non-virtual member,
an inheritance shape needing this adjusted, an unrecognised thunk, or an
architecture it has not been taught. When it refuses,
.on_slot(8, &I::Present, cb) names the index by hand with the signature still
checked.
An overlay on D3D12 has to follow the swap chain through mode changes, so it is a group of sites rather than one — and they must go in together or not at all:
// Each callback is invoke-shaped: (auto orig, Self*, <the method's args>...).
splice::Outcome overlay = splice::Vtable<IDXGISwapChain3>{chain}
.on(&IDXGISwapChain3::Present, on_present)
.on(&IDXGISwapChain3::Present1, on_present1)
.on(&IDXGISwapChain3::ResizeBuffers, on_resize)
.on(&IDXGISwapChain3::ResizeBuffers1, on_resize1)
.on(&IDXGISwapChain3::SetFullscreenState, on_fullscreen)
.on(&IDXGISwapChain3::SetColorSpace1, on_color_space)
.non_reentrant() // one scope for the whole group
.install(splice::live); // every site, or none of themA failure part-way restores the slots already written before any thread
resumes and reports State::recovered with nothing published;
State::refused means nothing was written at all. That is what a group buys
over six separate hooks: a failure at the fifth cannot leave four installed
with no record of them anywhere.
.non_reentrant() is there because these methods call each other — Present1
may reach Present internally. Within one scope, a nested call that arrives at
a hook of that scope goes straight to the original on that thread, so the
overlay does not draw twice and does not recurse. Nested calls do not consult
.when() and do not spend a .once() budget.
A D3D12 overlay usually also wants ID3D12CommandQueue::Signal, which is a
different interface — and one Vtable object names one table. A group is the
general noun; Vtable is one way to fill it:
using PresentFn = HRESULT(STDMETHODCALLTYPE*)(IDXGISwapChain*, UINT, UINT);
using SignalFn = HRESULT(STDMETHODCALLTYPE*)(ID3D12CommandQueue*, ID3D12Fence*, UINT64);
splice::Live<> group;
group.at(present_addr, PresentFn{})
.expect_prologue(present_bytes) // refuse unless these are still the bytes
.replace_with(&OnPresent, g_original_present);
group.at(signal_addr, SignalFn{})
.expect_prologue(signal_bytes)
.replace_with(&OnSignal, g_original_signal);
splice::Outcome overlay = group.install(splice::live);.at() takes the signature as a tag type, so the callback is still
compile-checked. This is also the shape to use when the addresses were resolved
somewhere else — out of process, or from a runtime the target loaded rather than
the one you did.
.replace_with(replacement, original) is the action verb for a caller that
already has its own hook function. .onInvoke() asks Splice to build one: a
stub is claimed, the callback goes into the registry, and the original arrives
as the first argument. A render-thread hook that is already a plain function,
and that reads what it displaced out of its own atomic with acquire because that
is the only load it will do per frame, wants neither — wrapping it adds an
indirection to the hottest path in the process and buys nothing. Gates are
refused with this verb rather than ignored: they are evaluated by Splice's
wrapper, and here your function is in the call path instead, so a .once()
would never run.
.install(splice::slot_swap) // store a pointer into the vtable slot
.install(splice::live) // inline-patch the function the slot points atsplice::slot_swap |
splice::live |
|
|---|---|---|
| What it writes | one pointer-sized atomic store into the slot | the function's prologue |
| Thread coordination | none needed | every thread Splice could enumerate, suspended once |
| Reaches | callers going through that table | every table pointing at the function, and every direct caller |
| Another overlay swaps the same slot | it displaces you, or you displace it | you survive — existing slot pointers stay valid and the call still arrives |
| Named by a plain address | refused — there is no slot to swap | supported |
Neither tier is scoped to an object, although Vtable takes one. A concrete
class has one vtable shared by every instance, so a slot swap made through one
object is seen by all of them — measured on two swap chains created from one
device, which share a table pointer. The coordinated tier is wider again. If one
object is what you meant, compare identity in the callback.
It is continued, not stepped over. Its program counter is mapped through that site's instruction-boundary map to the relocated copy of the instruction it was at. A program counter that is not at a boundary the decoder recognises is refused and nothing is written. Both halves are driven deterministically in the test suite — park a thread, set its program counter to each offset in turn, install, and read back what the thread computes, which says where it resumed rather than merely that it survived.
Two refusals worth knowing about, both found by a real consumer:
- A target somebody else already patched.
.expect_prologue(bytes)refuses unless the function still begins with the bytes you read. It is the only check that notices this, because the coordinated tier patches the function and not the table — so a vtable slot can be pristine while the code behind it is not. A site that does not ask is not checked. - A second install on the same function is refused, not chained. The group registers what it patched, so the next unwrap stops at Splice's own jump instead of walking into the relay behind it.
To decide before installing, splice_prologue_profile on the C ABI reports
what a coordinated install would replace at a target: the patch window, the
length of the instruction at the entry, and how many instruction starts lie
strictly inside the window and where they are. Zero interior boundaries means no
thread can be inside the bytes being replaced — it is either at the entry or
past all of them. It decodes and writes nothing. These are facts rather than a
verdict, deliberately: an overlay's admission rule is the overlay's, and the
shipping consumer's is stricter than this library requires.
splice::live means Splice coordinated the threads it could enumerate. A
thread created after that enumeration is neither covered nor detected, and that
gap has no detector. It is not a stop-the-world — that remains a non-goal, and
reaching one needs a thread entry-exclusion protocol or privileges this library
does not assume. The tier is named live and not safe for exactly that
reason.
splice::live_tier_available() reports whether it can run on a build at all: it
needs an architecture whose prologue can be planned and an OS that can suspend a
thread, which today means x86_64 on Windows. slot_swap has no such
requirement.
Every hooked call makes two registry lookups: one to find your callback, one to find the original function. Since 1.2.0 each is a single acquire-load from a flat array, and that is the whole lookup:
auto* hook = slots[id].load(std::memory_order_acquire); // no lock, no RMWNo mutex, no reference count, no atomic read-modify-write, nothing to reclaim.
The id is the index — slot_for(trampoline) hands out one slot per hook site,
so the array is indexed and never searched. The line a reader touches is one
nobody writes, so it stays Shared in every core's cache and a hooked call
generates no coherence traffic at all. That is why the contended numbers below
are nearly flat.
This is splice::registry::rcu_atomic_array, the default. The previous default,
shared_mutex_map, took a shared_lock and hashed the id — twice per call —
and cost about 91% of all single-thread dispatch. It is still supported; the
trade is set out further down.
One commit, the registry as the only difference between two binaries, both built before anything was measured, Release, callback dispatch in ns/call. x86_64 and Android use this repository's own Google Benchmark binaries, so those two are comparable line for line; the Jetson has no Google Benchmark installed and uses a standalone harness of the same shape.
An unhooked call is measured inside every binary as an in-run control, and that is not ceremony. A standalone harness on a governor-driven phone reported the same unhooked call at 3.67 ns under one thread and 1.36 under four — identical code apparently faster under load — while Google Benchmark held that control at 2.73 ns every round. Only the control catches it. It is also what vouches for the Jetson's harness below, where it holds at 3.32 ns across every thread count.
Windows x86_64 — Ryzen 9 9950X3D, VS2022, three interleaved rounds on a settled machine, medians:
| unhooked | rcu_atomic_array |
shared_mutex_map |
|
|---|---|---|---|
| 1 thread | 2.37 | 4.3 | 23.3 |
| 8 threads | — | 19 | ~1400 |
| 8t/1t | — | 4.5× | ~60× |
Android ARM64 — Snapdragon 8 Gen 3 / Android 14, NDK clang, on device, three rounds (ranges are the spread across them):
| threads | unhooked | rcu_atomic_array |
shared_mutex_map |
|---|---|---|---|
| 1 | 2.73 | 4.55–4.72 | 72.6–75.7 |
| 2 | — | 5.13–5.33 | 320–393 |
| 4 | — | 5.83–6.10 | 791–818 |
| 8 | — | 7.88–7.91 | 1774–1822 |
Splice adds 1.9 ns over an unhooked call here — the same 1.9 ns as on the 9950X3D, so there is no residual ARM64 dispatch penalty once the lock is gone. What is genuinely dearer on ARM64 is the lock itself, and the registry gap widens to 228× at eight threads.
Linux ARM64 — Jetson Orin Nano (6-core Cortex-A78AE, 15 W), gcc 13.3, built and run natively on the board:
| threads | unhooked | rcu_atomic_array |
shared_mutex_map |
|---|---|---|---|
| 1 | 3.32 | 11.95 | 100.8 |
| 2 | 3.33 | 11.97 | 1055.5 |
| 4 | 3.32 | 11.96 | 1950.8 |
| 6 | 3.32 | 11.97 | 2994.7 |
The lock-free path's six-thread cost equals its one-thread cost — a 6t/1t ratio of 1.00× — while the locked map reaches 250× the lock-free figure and costs 100 ns at one thread with nothing to contend with. This is the acquire-load-on-a-Shared-line property in the cleanest form of any board here. It is six threads on a six-core board, so it is not the eight-thread scaling gate below; the eight-thread run was never made here.
The registry on its own, driven directly with the trampoline out of the
picture (benchmark/bench_registry_lookup.cpp instantiates both
implementations in one binary, so these never depended on a build flag):
| lookup, 1 → 8 threads | rcu_atomic_array |
shared_mutex_map |
|---|---|---|
| callbacks, x86_64 | 0.96 → 5.24 | 10.1 → 522 |
| originals, x86_64 | 1.29 → 5.09 | 9.79 → 473 |
| callbacks, Snapdragon 8 Gen 3 | 3.35 → 3.86 | 36.8 → 967 |
| originals, Snapdragon 8 Gen 3 | 0.306 → 0.411 | 37.6 → 938 |
Both long-standing performance gates are met for the first time: under 20 ns
single-thread on all three boards, and 8t/1t under 5× on the two where eight
threads were run (4.5× on x86_64, 1.7× on the Snapdragon). Protocol, raw
rounds, and the measurement mistakes caught along the way, are in
docs/fr-010-step6-bench-results.md.
Two arrays, allocated once when the context is constructed and sized by the configured capacity — one pointer per slot for the callbacks, one per slot for the originals. At the default 512 on a 64-bit target that is 4 KiB each, 8 KiB together, and you pay all of it to install one hook. The locked map allocates per entry instead and starts at nothing, so a program with three hooks is about 8 KiB heavier under the default than it was under 1.1.0. The figure is deliberate: both arrays stay comfortably L1d-resident, which is what keeps the reader at an acquire-load.
Nothing is reclaimed. A hook object lives for the life of the process, and
so does the array. There was a reclamation scheme — publish an immutable
snapshot, free the retired one 100 ms later — and 1.2.0 removed it, because
elapsed time is not quiescence: a reader loads the slot pointer and then
dereferences it, and a thread preempted between those two steps, or stopped in
a debugger, or descheduled on a loaded phone, outlives any grace period.
Reclaiming correctly needs reader registration or hazard pointers, which costs
the hot path the entire reason this implementation exists. So it leaks, on
purpose, at the specific sites named in .lsan-suppressions.txt — the same
bargain the trampolines and the published callbacks already make here, said
plainly rather than hidden. clear() detaches slots without destroying what
was published, for the same reason.
The id is an array index, so there are SPLICE_MAX_HOOKS slots and no more —
512 by default, counted in distinct hook sites in the process, not in id
values and not in calls. Slots are issued in order and never recycled, so a
site that is installed and later disabled keeps its slot for the life of the
process.
Asking for one site past the capacity is a refused install: no slot is
issued, the reason is logged with the capacity in it, and nothing is written.
It used to abort() on the callback side and silently return a null original
on the other. There is no abort() left anywhere in the library — it is loaded
into somebody else's program, where that is a dead host, and on Windows a modal
runtime dialog that freezes the host and waits for a human. If the two
allocations fail outright at startup, the context comes up empty with a
capacity of zero and every registry-backed install is refused by name, host
still running.
Raise it at build time:
cmake -DSPLICE_MAX_HOOKS=4096 # 32 KiB per array; the readers leave L1dThat option is private to Splice's own build, which is the design and not
an oversight: since 1.2.0 the arrays live behind a pointer and a runtime count,
so nothing a consumer can see — no layout, no inline expression, no template
argument — depends on the value. A consumer compiled with a different one has
nothing it could misread, and so does not need to be told. Ask
splice::detail::configured_capacity() for what the library was built with.
cmake -DSPLICE_REGISTRY_IMPL=::splice::registry::shared_mutex_mapTwo reasons, and they are the only two:
- More hook sites than you want to bound. A map takes any id, so there is no capacity to exceed and no array to size.
- Hook objects you need reclaimed. The map holds each one in a
shared_ptr, so replacing or clearing an entry frees it.
Everything else is worse: about 5× the single-thread dispatch cost on x86_64,
16× on a Snapdragon 8 Gen 3, and an 8t/1t ratio that measures the cache line
under a shared_mutex's reader counter rather than anything your code does.
Unlike the capacity, this one is a choice for the whole program. It changes
sizeof(HookContext), and the compiled library owns the single instance every
translation unit shares, so it is applied PUBLIC on the splice target and
travels with splice::splice, including through install(EXPORT) — link the
target and you cannot disagree. Setting the macro by hand still can: Splice
compares the two sizes at first use, logs the mismatch, and refuses every
hook from the translation unit that disagrees rather than reading the shared
context at offsets that are not where that unit believes they are.
Grouped, coordinated installation — splice::Vtable, splice::Live,
splice::install_all(splice::live). One transaction: one thread enumeration and
one suspension for the whole set, all-or-nothing across it, with State and
Stage saying where an attempt stopped. lost is the one outcome a caller has
to decide something about — the threads are still suspended.
Why one transaction and not N: a thread enumeration costs about 118 ms, because the OS walks every thread on the machine rather than this process's. Seven sites installed separately pay that seven times. Measured end-to-end in the consuming overlay's own startup log, on the stage that contains the install:
| samples | install stage (ms) | |
|---|---|---|
| per-site installs (previous release) | 16 | 218 … 922, 1047, 1250, 1516, 1984, 2406 |
| one coordinated transaction (1.2.0) | 3 | 125, 188, 219 |
Three samples against sixteen, different games and targets, and the older samples span several branch states — so this is a mechanism confirmed, not a controlled A/B.
The lock-free registry is now the default, which is the largest performance change in this library's history: single-thread callback dispatch is 4.3 ns against 23.3 on x86_64 and 4.6 against 71.9 on a Snapdragon 8 Gen 3, because the locked map it replaces was about 91% of all single-thread dispatch cost. Every board, both scaling gates, the footprint it costs and the capacity it imposes are in The lock-free registry above.
Also in this release:
.non_reentrant()/.non_reentrant(scope)as a gate beside.when(),.once()and.times(n), with asplice::Reentrancyscope shareable by sibling hooks..replace_with(replacement, original),.expect(address),.expect_prologue(bytes),.observe()/invocations().- On the C ABI:
splice_live_prepare/_commit/_release,splice_resolve_symbol,splice_prologue_profile,splice_vtable_swap,splice_vtable_slot_of_pmf, andSPLICE_PATCH_STRATEGY_DATA_SLOT— a vtable slot's page may be read-only, writable or on the heap, so that path records the protection it found and puts that one back. SPLICE_REGISTRY_IMPLandSPLICE_MAX_HOOKSare real CMake cache options, validated at configure time. The registry choice is applied PUBLIC, so it travels withsplice::spliceand throughinstall(EXPORT), and a build whose translation units disagree about it is detected and refused instead of reading the shared context at the wrong offsets. The capacity is PRIVATE to Splice's own build, because the slot arrays now live behind a pointer and a runtime count: a consumer that disagrees about it has nothing it could misread.- Exceeding the registry capacity is a refused install with the reason
logged. It used to end the process with
abort()on one side and return silently on the other. There is noabort()left anywhere in the library: it is loaded into somebody else's program, where that is a dead host — and on Windows a modal runtime dialog that freezes the host and waits for a human. - An address that names nothing is refused instead of faulting;
splice::os::is_readableis consulted at every hop of a thunk walk.
Full detail, including what was corrected rather than added: CHANGELOG.
GpuThermalGuard — a power-limit guard for NVIDIA GPUs with an in-game overlay. Its overlay DLL uses Splice to hook the Direct3D 11 and Direct3D 12 present paths inside games that are already running: one site on D3D11, and seven on D3D12 — the swap chain's present, mode-change and colour-space methods plus one on the command queue — installed as a single coordinated transaction with a sixteen-byte prologue expectation per site.
Using Splice in a project? Open an issue or a PR to be listed here.
- Type safety first — signatures deduced via
decltype, novoid*in user code. - Fluent chainable API — reads top-to-bottom, verb-first, composable gates.
- Zero-friction for the 90% case — one-arg macros; escape hatches for the rest.
- Platform-honest — GOT/PLT on ELF, IAT on PE, vtable slots on COM, atomic inline patching everywhere.
- Hot-path aware logging — C macro substrate (not spdlog/fmt),
_ONCE/_EVERY_Nvariants inside callbacks. - Refuse rather than guess — an unresolvable slot, an unrecognised prologue, a program counter at no boundary, a capacity exceeded: each is a named refusal that writes nothing, not a best effort.
- Honest about limits — true reversible uninstall is out of scope, by a missing protocol rather than by a law: reclaiming the trampoline needs proof that nothing can reach it again, and nothing here establishes it. Tiered
disable()instead (Tier 1 reversible; Tier 2 atomic restore, trampoline leaks), said plainly — seedocs/splice-guide.md§9.
Spec-level review vs the field (full doc: docs/splice-vs-hooking-libraries.md):
| Splice | ShadowHook | Detours | PolyHook2 | SubHook | rcmp | |
|---|---|---|---|---|---|---|
| OS | Android/Linux/Windows | Android | Windows | Windows | Win/Linux/macOS | Win/Linux |
| Arch | ARM64 + x86_64 | arm32/arm64 | x86…ARM64 | x86/x64 | x86 | x86/x64 |
Type-safe decltype API |
✅ | ✖ (C) | ✖ (C) | partial | ✖ (C) | ✅ |
| Fluent DSL / RAII | ✅ | ✖ | ✖ | ✖ | ✖ | ✖ |
| Import-table + inline | ✅ | inline | ✅ | ✅ | inline | inline |
| Grouped all-or-nothing install | ✅ | ✖ | ✖ | ✖ | ✖ | ✖ |
| License | MIT | MIT | MIT | MIT | BSD-2 | MIT |
The niche: the only library here that is both modern-C++ (the compiler checks your hook) and spans ARM64 + x86_64 across Android + Linux + Windows. Each rival is a single-OS or single-arch specialist. Rivals lead where they should: ShadowHook on Android battle-testing + unhook, Detours on Windows true-uninstall + injection, PolyHook2 on hook-type variety.
| Platform | Status | Install strategies |
|---|---|---|
| Android ARM64 | ✅ Production | inline patch (live-verified, Snapdragon 8 Gen 3) |
| Linux ARM64 | ✅ Supported | GOT/PLT + inline (verified on Jetson Orin Nano) |
| Linux x86_64 | ✅ Supported | GOT/PLT + inline |
| Windows x86_64 | ✅ Production | IAT + inline + vtable slot; coordinated splice::live tier |
| Android ARM32 | ⏸ Optional | later (FR-006) |
The coordinated live tier needs an OS that can suspend a thread and an
architecture whose prologue can be planned — today x86_64 on Windows. Ask
splice::live_tier_available() rather than assuming. slot_swap and every
single-site path run everywhere above.
CI builds + tests Windows x64, Linux x64 (+ASan), Linux ARM64, and
cross-compiles Android ARM64 on every push. On-device ARM64 verification is a
manual step on real hardware. See docs/splice-guide.md §5 / §9.
Import-Module "C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
Enter-VsDevShell -VsInstallPath "C:\Program Files\Microsoft Visual Studio\2022\Community" -DevCmdArguments "-arch=x64 -host_arch=x64"
$env:VCPKG_ROOT = "C:\vcpkg"
cmake --preset=windows-x64-dev
cmake --build --preset=windows-x64-dev
ctest --preset=windows-x64-dev --output-on-failureexport VCPKG_ROOT=/path/to/vcpkg
cmake --preset=linux-x64-dev
cmake --build --preset=linux-x64-dev
ctest --preset=linux-x64-dev --output-on-failurefind_package(splice CONFIG REQUIRED)
target_link_libraries(my_app PRIVATE splice::splice)Or zero-install via FetchContent / vendoring — add_subdirectory(splice) and
the splice::splice alias work either way.
The examples/ directory is the fastest way in:
| Example | Shows |
|---|---|
hello_hook |
the whole API in 40 lines |
malloc_tracker |
hook a libc function, allocation-free accounting |
member_function |
SPLICE_HOOK_MEMBER on a C++ method |
gpu_app/ |
Paradigm A — patch GLES-style commands directly to upscale a render |
vulkan_app/ |
Paradigm B — hook vkGetDeviceProcAddr once and substitute wrapper pointers (the real Vulkan-layer / RenderDoc pattern) |
Both GPU demos run the same unmodified "game" at 160×120, and Splice transparently upscales it to 640×480 — the GLES one by patching each command, the Vulkan one by rewriting the dispatch getter's return value:
| Baseline (160×120) | Splice-enhanced (640×480) |
|---|---|
![]() |
![]() |
Paradigm A — patch the command directly (GLES / libc / game functions):
// gpu_app: rewrite the viewport command's ARGUMENTS to upscale
SPLICE_HOOK_ADDR(&gpu::set_viewport)
.when([]{ return g_enabled; })
.onInvoke([](auto orig, int w, int h) { orig(w * 4, h * 4); });Paradigm B — hook the dispatcher, rewrite its RETURN VALUE (Vulkan, plugin dlsym):
// vulkan_app: hook vkGetDeviceProcAddr ONCE; hand the app wrapper pointers.
// This is how RenderDoc / MangoHud / Vulkan layers actually intercept.
SPLICE_HOOK_ADDR(&vk_get_device_proc_addr)
.onInvoke([](auto orig, VkDevice dev, const char* name) -> PFN_vkVoidFunction {
auto real = orig(dev, name);
if (std::strcmp(name, "vkCmdDraw") == 0) {
g_real_draw = (PFN_CmdDraw)real; // keep the real pointer
return (PFN_vkVoidFunction)&wrapped_draw; // return our wrapper instead
}
return real; // pass everything else through
});One Splice hook on the getter influences every Vulkan command — and because
the commands themselves are never patched, even libc-heavy ones stay hookable.
The .onInvoke here rewrites a function pointer return; in Paradigm A it
rewrites arguments. Same fluent API, two interception strategies.
Paradigm C — the interface is already a table of pointers (COM, Direct3D):
swap the slot, or patch the function behind it. That is what <splice/live.h>
exists for — see the Direct3D section.
cmake --build --preset=windows-x64-dev --target splice_gpu_demo splice_vulkan_demo
./out/build/windows-x64-dev/examples/gpu_app/gpu_demo
./out/build/windows-x64-dev/examples/vulkan_app/vulkan_demoParadigms A and B are explained in docs/splice-guide.md §11.
Full capability guide: docs/splice-guide.md (Traditional Chinese translation: docs/splice-guide-zh.md).
include/splice/ Public API (header-only)
splice.h Umbrella header + version constants
macros.h SPLICE_HOOK* / _MEMBER / _AS family
core.h InterceptorEntry<> + fluent modifiers + ScopedHook
live.h splice::Vtable / Live / Outcome, slot_swap + live tiers
gates.h .when / .once / .times / .non_reentrant, shared by site and group
reentrancy.h splice::Reentrancy scopes
context.h HookContext, HookStorage, HookRegistry (policy/registry)
policy.h rcu_writeonce / shared_mutex concurrency policies
registry.h hook + originals registry surface
registry_impl.h SPLICE_REGISTRY_IMPL (rcu_atomic_array default / shared_mutex_map)
trampoline.h per-call-site trampoline generator
traits.h function_traits + member_function_traits
diagnostics.h SPLICE_TRACE / _COUNT / _TIME sugar
log.h SPLICE_LOG* + hot-path variants
engine.h C ABI to the compiled engine
detail/platform_log.h Vendored C logging substrate
src/ Compiled library
engine/ engine dispatcher + GOT/IAT patchers + live transaction
arch/{arm64,x86_64}/ disasm + inline patcher + atomic install
os/{posix,win32}/ memory + module enumeration + thread suspension
tests/ GTest (326 cases)
benchmark/ Google Benchmark microbenches
examples/ hello_hook, malloc_tracker, member_function, gpu_app, vulkan_app
docs/ splice-guide.md (canonical) + comparisons + performance reports
cmake/ spliceConfig.cmake.in (find_package support)
v1.0.0 — shipped: Phases 0–4.5 + Fluent API v2. Highlights:
- Cross-platform engine (ARM64 + x86_64; Android/Linux/Windows), GOT/PLT + IAT + atomic inline patching.
- Performance pass — 41 → 22 ns/call single-thread; opt-in RCU registry for many-thread readers.
- Fluent API v2 —
.before/.after/.when/.once/.times,SPLICE_HOOK_MEMBER, diagnostics,ScopedHook. - Tiered
disable(),find_package(splice)install/export.
v1.1.0 — shipped (2026-10-06): live installation into running processes. Highlights:
- Near trampolines + relay: atomic 5-byte x86_64 installs regardless of where the hook lives.
- Strict exact-site installation; internal prepared patches and an enlisted-thread transaction (Windows x64).
- ARM64 near allocation so
ADRP/Bprologues relocate;.observe()/invocations(). - Fail-closed fixes: refused stacking instead of recursion, Tier 1 made truly atomic,
splice_disablerestored on MSVC prologues.
v1.2.0 — shipped (2026-10-13): the grouped, coordinated install, and the registry that pays for it. Highlights:
splice::Vtable/splice::Live/install_all(splice::live)— one transaction, one thread enumeration, all-or-nothing, with named outcomes.- COM / vtable hooking as a first-class path; Direct3D 11 and Direct3D 12 in production.
.non_reentrant(),.replace_with(),.expect_prologue(),splice_prologue_profile.- Lock-free registry by default: 4.3 ns/call on x86_64, 4.6 on Snapdragon 8 Gen 3.
- No
abort()anywhere in the library; capacity and configuration mismatches are refusals.
Next (optional): chaining onto patches installed by others; the live tier
beyond Windows x86_64; ADR materialisation on ARM64; Android ARM32 (FR-006);
macOS is research-only (see docs/macos-port-notes.md).
MIT — see LICENSE.

