Hooking
Source2Toolkit hooks with KHook — Metamod:Source's own detour library, on the one engine Metamod runs for the whole server. If you have written a Metamod 2.0 plugin, you already know the API.
One engine for everyone
The toolkit does not run a hook engine of its own. It takes KHook from
Metamod when it loads and hands that same engine to every plugin through
TOOLKIT_KHOOK_INTERFACE.
This is deliberate. CallOriginal and Supersede walk the handler chain of
the engine that owns the patch. Two independent engines patching the same
address cannot see through each other — one of them silently calls the other's
trampoline instead of the real original. One engine means one chain per
address, so the core's hooks, your plugin's hooks and the hooks of the Metamod
plugins next to you compose correctly.
You never fetch it. TOOLKIT_EXPOSE declares the globals and
TOOLKIT_SAVEVARS fills them:
Plugin g_Plugin;
TOOLKIT_EXPOSE(my_plugin, g_Plugin);
bool Plugin::Load(PluginId id, IToolkitAPI* api, char* error, size_t maxlen, bool late)
{
TOOLKIT_SAVEVARS(); // KHook is live from here on
...
}A hook is an object
Every hook is an object holding the hooked function, a context (your plugin)
and a Pre and a Post callback. Its type spells out the class, the return type
and the arguments. The callbacks are members of your class: they take the
hooked object as their first parameter and return KHook::Return<T>.
Keep hooks behind plain pointers. A KHook hook only comes down in its
destructor, so it is new in the constructor and delete in Unload().
KHOOK_NEW (from IToolkitTypes.h) writes the mem-initializer without
repeating the type — MSVC cannot deduce it from the arguments.
class Plugin final : public IToolkitPlugin
{
public:
Plugin();
bool Load(PluginId id, IToolkitAPI* api, char* error, size_t maxlen, bool late) override;
bool Unload(char* error, size_t maxlen) override;
KHook::Return<void> Hook_GameFrame(ISource2Server* pThis, bool simulating, bool bFirstTick, bool bLastTick);
KHook::Return<bool> Hook_FireEvent(IGameEventManager2* pThis, IGameEvent* event, bool bDontBroadcast);
private:
KHook::Virtual<ISource2Server, void, bool, bool, bool>* m_hGameFrame = nullptr;
KHook::Virtual<IGameEventManager2, bool, IGameEvent*, bool>* m_hFireEvent = nullptr;
};
Plugin::Plugin() :
KHOOK_NEW(m_hGameFrame, &ISource2Server::GameFrame, this, nullptr, &Plugin::Hook_GameFrame), // Post only
KHOOK_NEW(m_hFireEvent, &IGameEventManager2::FireEvent, this, &Plugin::Hook_FireEvent, nullptr) // Pre only
{
}The two callback slots are Pre and Post. Pass both if you need both sides;
nullptr leaves a side empty.
Virtual hooks
The common case: you have an interface pointer and want one of its virtual methods. The hook takes the index out of the member function pointer, so nothing is written down by hand. Attach it to the instance:
bool Plugin::Load(PluginId id, IToolkitAPI* api, char* error, size_t maxlen, bool late)
{
TOOLKIT_SAVEVARS();
m_hGameFrame->Add(g_pSource2Server);
m_hFireEvent->Add(g_pGameEventManager);
return true;
}Hooking a whole vtable
When you have a vtable but no instance — engine classes resolved by RTTI
name — AddGlobal reads the vtable off an object and covers every instance
that shares it. It only ever looks at the first pointer, so a plain pointer
holding the vtable is a valid stand-in:
KHook::Virtual<CServerSideClientBase, bool, const CNetMessage*, NetChannelBufType_t>* m_hSendNetMessage = nullptr;
void* m_pCServerSideClientVTable = nullptr;
// Load()
m_pCServerSideClientVTable = libengine->GetVirtualTableByName("CServerSideClient").RCast<void*>();
m_hSendNetMessage->AddGlobal(reinterpret_cast<CServerSideClientBase*>(&m_pCServerSideClientVTable));This hooks the class, not one instance — every client goes through it, and
pThis tells you which one.
When the index only exists in gamedata, construct the hook with a placeholder
index and Configure it once the offset is known:
KHOOK_NEW(m_hRespawn, 0u, this, &Plugin::Hook_Respawn, nullptr)
// Load()
const int offset = GAMECONFIG_OFFSET("CCSPlayerController::Respawn");
if (offset >= 0)
{
m_hRespawn->Configure(offset);
m_hRespawn->AddGlobal(reinterpret_cast<CCSPlayerController*>(&m_pCCSPlayerControllerVTable));
}Refuse a missing offset. GetOffset answers -1 for an unknown key, and a
hook configured at -1 lands one slot before the vtable — on the
typeinfo pointer, which corrupts every later by-name vtable lookup on that
class.
Function hooks
Anything a signature scan can find is hookable — no vtable, no interface.
KHook::Member is for a function with a this, KHook::Function for a free
function. The type spells out the class, the return type, then the arguments
excluding this:
// bool INetworkMessageProcessingPreFilter::FilterMessage(const CNetMessage*, INetChannel*)
KHook::Member<INetworkMessageProcessingPreFilterCustom, bool, const CNetMessage*, INetChannel*>* m_hFilterMessage = nullptr;
// void CEntityIOOutput::FireOutputInternal(CEntityInstance*, CEntityInstance*, void*, float, void*, void*)
KHook::Member<CEntityIOOutput, void, CEntityInstance*, CEntityInstance*, void*, float, void*, void*>* m_hFireOutputInternal = nullptr;
Plugin::Plugin() :
KHOOK_NEW(m_hFilterMessage, this, &Plugin::Hook_FilterMessage, nullptr),
KHOOK_NEW(m_hFireOutputInternal, this, &Plugin::Hook_FireOutputInternal, nullptr)
{
}Configure places the detour; whatever your signature scan produced goes in
directly:
if (void* addr = GAMECONFIG_RESOLVE("INetworkMessageProcessingPreFilter::FilterMessage"))
m_hFilterMessage->Configure(addr);Function hooks are the answer when a class has a non-primary vtable —
a secondary vtable is a separate table elsewhere in .rodata, and a
by-name lookup only ever finds the primary one. That is exactly why the
toolkit hooks FilterMessage by signature rather than by vtable.
Inside a handler
The object that was actually called arrives as the first parameter; the rest are the function's own arguments:
KHook::Return<void> Plugin::Hook_FireOutputInternal(CEntityIOOutput* pThis, CEntityInstance* pActivator, CEntityInstance* pCaller,
void* variantValue, float delay, void* unk01, void* unk02)
{
const char* outputName = pThis->m_pDesc->m_pName;
...
return { KHook::Action::Ignore };
}A handler returns KHook::Return<T>: the action, plus the value when the
hooked function returns one:
return { KHook::Action::Supersede }; // void handlers
return { KHook::Action::Override, false }; // handlers with a return typeKHook::Action | meaning |
|---|---|
Ignore | the handler did nothing |
Override | the original still runs, but your return value is used (Pre only) |
Supersede | the original is skipped entirely, your return value is used (Pre only) |
The toolkit's own listeners — REG_CON_LISTENER, HOOK_GAME_EVENT, net
message hooks, entity output listeners — return Action, which is the same
type: IToolkitTypes.h aliases it to KHook::Action.
Supersede skips the original for every remaining Pre handler too. If
you called the original yourself and only want to stop KHook calling it a
second time, supersede is still the right answer — but hooks registered
after yours will not see it run.
Calling the original
From a Pre handler, CallOriginal runs the real function with the arguments
you give it — then supersede, so KHook does not run it a second time:
sv_autobunnyhopping.Set(true);
m_hCheckJumpButtonLegacy->CallOriginal(pThis, mv);
sv_autobunnyhopping.Set(false);
return { KHook::Action::Supersede };In a Post handler the original has already run; its result is a call away:
unsigned long id = KHook::GetOriginalReturn<unsigned long>();Removing hooks
Detach what you attached, then delete — deleting is what takes the detour down:
bool Plugin::Unload(char* error, size_t maxlen)
{
m_hGameFrame->Remove(g_pSource2Server);
m_hFireEvent->Remove(g_pGameEventManager);
if (m_pCServerSideClientVTable)
m_hSendNetMessage->RemoveGlobal(reinterpret_cast<CServerSideClientBase*>(&m_pCServerSideClientVTable));
delete m_hGameFrame;
delete m_hFireEvent;
delete m_hSendNetMessage;
delete m_hFilterMessage;
m_hGameFrame = nullptr;
m_hFireEvent = nullptr;
m_hSendNetMessage = nullptr;
m_hFilterMessage = nullptr;
return true;
}Delete every hook you placed before your plugin unloads. A live hook pointing into an unmapped library crashes the server on the next call.
Coming from SourceHook
Earlier Source2Toolkit builds ran a private SourceHook engine. The mapping is:
| SourceHook | KHook |
|---|---|
SH_DECL_HOOK* + SH_ADD_HOOK | KHook::Virtual<...> + Add |
SH_ADD_DVPHOOK / SH_ADD_MANUALDVPHOOK | AddGlobal on a vtable stand-in, Configure(index) for a gamedata index |
SH_DECL_INLINEHOOK* + SH_ADD_INLINEHOOK | KHook::Member<...> / KHook::Function<...> + Configure(addr) |
META_IFACEPTR / SH_IFACEPTR | the first handler parameter |
RETURN_META / RETURN_META_VALUE | return { action } / return { action, value } |
MRES_IGNORED / MRES_HANDLED | KHook::Action::Ignore |
MRES_OVERRIDE | KHook::Action::Override |
MRES_SUPERCEDE | KHook::Action::Supersede |
SH_CALL(...) | hook->CallOriginal(pThis, ...) |
SH_RESULT_ORIG_RET(T) | KHook::GetOriginalReturn<T>() |
SH_REMOVE_HOOK_ID | Remove / RemoveGlobal, then delete |
KHook::Action and META_RES are not numerically compatible — META_RES
has an extra MRES_HANDLED at 1, which shifts everything after it. There is
nothing left to cast between: the toolkit's Action is KHook::Action.