Source2Toolkit

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 type
KHook::Actionmeaning
Ignorethe handler did nothing
Overridethe original still runs, but your return value is used (Pre only)
Supersedethe 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:

SourceHookKHook
SH_DECL_HOOK* + SH_ADD_HOOKKHook::Virtual<...> + Add
SH_ADD_DVPHOOK / SH_ADD_MANUALDVPHOOKAddGlobal on a vtable stand-in, Configure(index) for a gamedata index
SH_DECL_INLINEHOOK* + SH_ADD_INLINEHOOKKHook::Member<...> / KHook::Function<...> + Configure(addr)
META_IFACEPTR / SH_IFACEPTRthe first handler parameter
RETURN_META / RETURN_META_VALUEreturn { action } / return { action, value }
MRES_IGNORED / MRES_HANDLEDKHook::Action::Ignore
MRES_OVERRIDEKHook::Action::Override
MRES_SUPERCEDEKHook::Action::Supersede
SH_CALL(...)hook->CallOriginal(pThis, ...)
SH_RESULT_ORIG_RET(T)KHook::GetOriginalReturn<T>()
SH_REMOVE_HOOK_IDRemove / 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.

On this page