Game Hooks

ISource2ModGameHooks — the game functions plugins hook most, detoured once by the core from its gamedata; a handler and a context instead of a KHook, a signature and a prototype of your own.

Most plugins hook the same few dozen game functions: TakeDamage, CanAcquire, PostThink, the movement services, the jump code. Every one of them written as a KHOOK_MEMBER ties the plugin to that function's exact prototype and to a signature of its own — and when Valve moves an argument or changes the order between Windows and Linux, every plugin has to be rebuilt.

ISource2ModGameHooks holds those hooks in the core. You register a handler; the core resolves the function from its gamedata, places the detour the first time anybody listens and takes it out again once the last listener is gone. A changed prototype is fixed in the core's one call site, and the plugins keep running. This is what puts a plugin on the stable tier — see Compatibility.

plugin.cpp
#include "source2mod/ISource2ModGameHooks.h"

bool MyPlugin::Load(PluginId id, ISource2ModAPI* api, char* error, size_t maxlen, bool late)
{
    SOURCE2MOD_SAVEVARS();

    // Pre: halve every hit on a player.
    m_hDamage = g_pSource2ModGameHooks->HookTakeDamage([](TakeDamageContext& ctx, bool post) -> Action
    {
        if (ctx.entity && ctx.entity->IsPlayerPawn())
            ctx.info->m_flDamage = ctx.info->m_flDamage() * 0.5f;   // by pointer: the original sees it

        return Action::Ignore;
    }, false);

    // Post: react to what CanAcquire decided -- a method as the handler.
    g_pSource2ModGameHooks->HookCanAcquire(SOURCE2MOD_MEMBER(this, &MyPlugin::OnCanAcquire), true);

    return true;
}

There is no KHOOK_INIT() in this: the plugin declared no hook of its own. No plugin id either — whose a handler is, the core reads off the handler itself (see Handlers and owners).

The handler

Every hook hands its handler a context — a plain struct with the hooked object, the function's arguments and, where the function returns something, result — plus post, which pass this is. The handler answers the way a KHook handler does: an action, and for a function with a return value the value that goes with it (GameHookReturn).

AnswerPrePost
Action::Ignorenothing changednothing changed
{ Action::Override, value }the original still runs, the function returns valuethe function returns value instead of the original's
{ Action::Supersede, value }the original does not run, the function returns value; the remaining pre handlers are skipped—
// Nobody moves while frozen: CanMove returns false.
g_pSource2ModGameHooks->HookCanMove([](CanMoveContext& ctx, bool post) -> GameHookReturn<CanMoveContext::Return>
{
    if (IsFrozen(ctx.pawn))
        return { Action::Supersede, false };

    return Action::Ignore;
}, false);

An action alone takes its value from ctx.result, so setting the field and returning Action::Supersede works too — but keep the two in one statement: an if without braces around ctx.result = x; return Action::Supersede; guards only the first of them. A function without a return value (PostThink, the movement functions) answers the action alone.

Arguments passed by pointer (the damage info, the move data, the weapon) can be changed in place and the original sees the change. Arguments passed by value (a speed, a flag) cannot; a handler that needs to change one keeps a raw KHOOK_MEMBER of its own and uses KHook::Recall.

Handlers run in registration order, pre before the original, post after it. In post, ctx.result holds what the original returned. A value a handler returns lands in ctx.result right away, so the next handler sees it. A Supersede stops the remaining pre handlers — the same rule as command listeners.

What the game would do: CallOriginal

ctx.CallOriginal() runs the game's function now, with the arguments the game passed, past every hook on it — SourceHook's SH_CALL — and returns what it returned. From a pre handler that is how you learn what the game would answer:

g_pSource2ModGameHooks->HookCanAcquire([](CanAcquireContext& ctx, bool post) -> GameHookReturn<CanAcquireContext::Return>
{
    const AcquireResult game = ctx.CallOriginal();

    if (game == AcquireResult::Allowed && IsBlockedHere(ctx))
        return { Action::Supersede, AcquireResult::NotAllowedByMode };

    return { Action::Supersede, game };   // it has run already -- do not let it run twice
}, false);

In a pre handler the function really runs, side effects and all, so a handler that calls it answers Supersede with the value; with Ignore the game would run it a second time. A changed pointed-to object (the damage info, the move data) counts in that call; a scalar field changed in the context does not. In a post handler ctx.result already holds the game's answer.

The same trick without a value — the game's own code, run with a setting flipped for one call:

CConVarRef<bool> sv_autobunnyhopping("sv_autobunnyhopping");

g_pSource2ModGameHooks->HookCheckJumpButtonLegacy([](LegacyJumpContext& ctx, bool post) -> Action
{
    if (sv_autobunnyhopping.Get())
        return Action::Ignore;

    sv_autobunnyhopping.Set(true);
    ctx.CallOriginal();
    sv_autobunnyhopping.Set(false);

    return Action::Supersede;
}, false);

Which functions

The set is the one Swiftly hooks, which is what most plugins reach for:

GroupHooks
Damage and itemsTakeDamage, CanAcquire, CanUse, DropWeapon
PlayerCanMove, PostThink, ProcessUsercmds, SimulateUserCommands, RunCommand
EntitiesAcceptInput, Touch
Movement servicesAirAccelerate, AirMove, CanUnduck, CategorizePosition, CheckFalling, CheckParameters, CheckVelocity, CheckWater, Duck, Friction, FullWalkMove, GroundAccelerate, LadderMove, MoveInit, PlayerMove, ProcessMovement, SetupMove, TryPlayerMove, WalkMove, WaterMove
JumpOnJumpLegacy, OnJumpModern, CheckJumpButtonLegacy, CheckJumpButtonModern

Every context is listed in the ISource2ModGameHooks reference. Two things to know:

  • GroundAccelerate's arguments come in a different order on Windows and Linux. The context always has them in one order; that swap is the core's.
  • Touch is a vtable hook on CBaseEntity's own table, so an entity class that overrides Touch is not seen. CanUse and DropWeapon are vtable hooks on the weapon services; everything else is found by signature.

IsAvailable(GameHook::X) says whether the core's gamedata has the function on this platform. Handlers can be registered either way; on a missing entry they never run.

Handlers and owners

A handler is a Source2ModCallback: a lambda, a free or static function, or an object and one of its methods — SourceHook's SH_STATIC / SH_MEMBER, spelt SOURCE2MOD_STATIC(func) / SOURCE2MOD_MEMBER(this, &Class::Method) (or just func and { this, &Class::Method }). Whose it is, the core reads off the handler: its code lies in the plugin's library, and that is the owner. No call takes a plugin id, and what a plugin still holds when it unloads, the core drops for it — nothing to do in Unload().

HookX() returns a GameHookId. A plugin may put several handlers on one function — a pre and a post, two features that both care about damage — and drop any one of them with UnhookX(id), or with the same function or method it hooked with (a lambda has no such identity; it goes by its id):

GameHookId m_hDamage = 0;

m_hDamage = g_pSource2ModGameHooks->HookTakeDamage(OnTakeDamage, false);
g_pSource2ModGameHooks->HookPostThink(SOURCE2MOD_MEMBER(this, &MyPlugin::OnPostThink), true);
...
g_pSource2ModGameHooks->UnhookTakeDamage(m_hDamage);
g_pSource2ModGameHooks->UnhookPostThink(SOURCE2MOD_MEMBER(this, &MyPlugin::OnPostThink), true);

Unhooking from inside the handler is fine: the detour itself comes out on the next frame, never from inside its own dispatch.

Debugging

s2m hookdebug <part of a hook name|all|off> in the server console logs every call of the matching game hooks: each plugin's answer, the outcome and the return value; TakeDamage adds the victim, the damage and the damage bits.

Source2Mod hookdebug TakeDamage
... hookdebug CBaseEntity::TakeDamageOld pre: JailBreak.smx -> Supersede
... hookdebug CBaseEntity::TakeDamageOld pre: Supersede (return 0) victim=func_breakable damage=25.000000 bits=4
Source2Mod hookdebug off

When to still hook yourself

  • The function is not in the set.
  • You need to change an argument passed by value, or call the original with different arguments (KHook::Recall).

Those are raw hooks — KHOOK_MEMBER, KHOOK_VIRTUAL — and they put the plugin on the raw tier: it hooks through KHook itself and is rebuilt when the function changes. s2m list shows which tier each plugin is on.

Reference

On this page