Hook Points

The typed points of ISource2ModHooks — the game functions plugins hook most and the engine callbacks, each with its own context, detoured once by the core from its gamedata.

Most plugins hook the same few dozen game functions — TakeDamage, CanAcquire, PostThink, the movement services, the jump code — and the same engine calls: the frame, clients connecting and leaving. Each of them is a typed point of ISource2ModHooks. You register a handler; the core resolves the function from its gamedata, places the detour and hands your handler a context with the call's arguments. When Valve moves an argument or swaps the order between Windows and Linux, the core's one call site changes and the plugins keep running.

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

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

    // Pre, every entity: scale the damage. A method as the handler.
    g_pSource2ModHooks->Hook(HookPoint::TakeDamage, HookTarget::All(), SOURCE2MOD_MEMBER(this, &MyPlugin::OnTakeDamage));

    // Post, every pawn every tick: keep it cheap.
    g_pSource2ModHooks->Hook(HookPoint::PostThink, HookTarget::All(), [](PostThinkContext& ctx, bool post) -> HookReturn<void>
    {
        return Action::Ignore;
    }, true);

    return true;
}

HookReturn<TakeDamageContext::Return> MyPlugin::OnTakeDamage(TakeDamageContext& ctx, bool post)
{
    if (ctx.info)
        ctx.info->m_flDamage *= 0.5f;   // by pointer: the original sees it

    return Action::Ignore;
}

Game functions

GroupPointsContext
Damage and itemsTakeDamage, CanAcquire, CanUse, DropWeaponTakeDamageContext, CanAcquireContext, CanUseContext, DropWeaponContext
PlayerCanMove, PostThink, ProcessUsercmds, SimulateUserCommands, RunCommandCanMoveContext, PostThinkContext, ProcessUsercmdsContext, SimulateUserCommandsContext, RunCommandContext
EntitiesAcceptInput, TouchAcceptInputContext, TouchContext
Movement servicesAirMove, CanUnduck, CheckFalling, CheckParameters, CheckWater, Duck, Friction, LadderMove, MoveInit, PlayerMove, ProcessMovement, WalkMove, WaterMoveMovementContext
AirAccelerate, GroundAccelerate, CategorizePosition, CheckVelocity, FullWalkMove, SetupMove, TryPlayerMoveone context each
JumpOnJumpLegacy, CheckJumpButtonLegacy / OnJumpModern, CheckJumpButtonModernLegacyJumpContext / ModernJumpContext
ChatPlayerChat — Host_Say, a say / say_team line the game prints: controller (null for the console), message, teamOnly, args; pre may Supersede to drop the line (three ways to see chat)PlayerChatContext

Every context holds the hooked object (entity, services, pawn, controller, identity, jump), the function's arguments and, where the function returns something, result. The fields are listed in the contexts reference (C++) and in source2mod/hookcontexts.inc (SourcePawn, as methodmaps).

  • 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 Function point on the same function can.
  • 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.
  • The movement context is shared by void and bool functions: result matters for CanUnduck, CheckWater, LadderMove and MoveInit only.

Entity virtuals (SDKHooks)

Functions that most entity classes override, hooked like SourceMod's SDKHooks: on the vtable of the target's own class, so the handler runs for the entity's own override.

PointThe callContext
SpawnCBaseEntity::Spawn(keyValues) — the keyvalues are applied hereSpawnContext
UseCBaseEntity::Use(info) — a player's +use, a map's Use input; info (activator, caller, use type, value) can be changed in preUseContext
StartTouch, EndTouch, BlockedCBaseEntity::StartTouch / EndTouch / Blocked(other)EntityOtherContext
ThinkCBaseEntity::Think() — the entity's think function, each time it runsThinkContext
KilledCBaseEntity::Event_Killed(damage) -> bool — health reached 0 and m_lifeState is already "dying"; Supersede skips the death handlingKilledContext
WeaponPickupCCSPlayer_WeaponServices::PickupWeapon(weapon) -> 0 / 1 / 2 (refused / taken / taken as ammo) — touching or +using a weaponWeaponPickupContext
WeaponSwitchCCSPlayer_WeaponServices::SwitchToWeapon(weapon, viewModelIndex) -> boolWeaponSwitchContext
  • Hook the entity points on one entity (HookTarget::Instance(entity), SourcePawn HookInstance: SourceMod's SDKHook) or on a class (HookTarget::Class("CCSPlayerPawn"), HookClass: every entity of that C++ class or one derived from it). HookTarget::All() is refused for them — every entity class has its own override, there is no one function to hook.
  • WeaponPickup and WeaponSwitch are on the weapon services, which every player shares: HookTarget::All() works for them.
  • A hook on one entity goes with the entity when it is removed.
  • Each one is a gamedata entry with a "function" object (CBaseEntity::Use, …), so Find returns the same function for calling it.
plugin.cpp
// Every smoke grenade a player throws: the moment it spawns.
g_pSource2ModHooks->Hook(HookPoint::Spawn, HookTarget::Class("CSmokeGrenadeProjectile"),
    [](SpawnContext& ctx, bool post) -> HookReturn<void>
    {
        return Action::Ignore;
    }, true);

// One button: who pressed it.
g_pSource2ModHooks->Hook(HookPoint::Use, HookTarget::Instance(button),
    [](UseContext& ctx, bool post) -> HookReturn<void>
    {
        if (ctx.info && ctx.info->activator)
            SOURCE2MOD_LOG(nullptr, "used by %s\n", ctx.info->activator->GetClassname());
        return Action::Ignore;
    });

// No player picks up an AWP.
g_pSource2ModHooks->Hook(HookPoint::WeaponPickup, HookTarget::All(),
    [](WeaponPickupContext& ctx, bool post) -> HookReturn<std::int32_t>
    {
        if (ctx.weapon && !V_strcmp(ctx.weapon->GetClassname(), "weapon_awp"))
            return { Action::Supersede, 0 };
        return Action::Ignore;
    });

Engine callbacks

The engine calls Source2Mod already hooks for itself, handed on as points. They take HookTarget::All() (Source2ModHooks.Hook in a script) only. Pre runs before the engine's own code, post after it.

PointContextNotes
GameFrameGameFrameContext: simulating, firstTick, lastTickevery tick; simulating is false while the game is not ticking (paused, hibernating)
StartupServerStartupServerContext: config, session, mapNamea new map's server is up
ClientConnectClientConnectContext: slot, name, xuid, networkId, rejectReason, resultpre may refuse the connection — see below. Not called for bots.
ClientConnectedClientConnectedContext: slot, name, xuid, networkId, address, fakePlayerthe slot is taken — bots too
ClientPutInServerClientPutInServerContext: slot, name, type, xuidtype 0 a player, 1 a bot
ClientDisconnectClientDisconnectContext: slot, reason, name, xuid, networkIdpre while the client is still in game, post once it is gone
ClientVoiceClientSlotContext: slot
ClientSettingsChangedClientSlotContext: slot
GameServerSteamAPIActivated, GameServerSteamAPIDeactivatedEmptyContext
LoadEventsFromFileLoadEventsFromFileContext: manager, filename, searchAlla resource file of game events; the place to keep the manager

These contexts have no CallOriginal: the handlers run around the engine's call, and only ClientConnect returns a value. In SourcePawn a SteamID (xuid) is decimal text (GetXuid), strings come through getters (GetName, GetMapName, …), and StartupServerContext has the map name only, LoadEventsFromFileContext no manager.

Refusing a connection

ClientConnect pre answers Supersede with false to turn the client away, with the reason in rejectReason. The first handler that refuses ends it.

plugin.cpp
g_pSource2ModHooks->Hook(HookPoint::ClientConnect, HookTarget::All(), SOURCE2MOD_MEMBER(this, &MyPlugin::OnClientConnect));

HookReturn<bool> MyPlugin::OnClientConnect(ClientConnectContext& ctx, bool post)
{
    if (IsBanned(ctx.xuid))
    {
        ctx.rejectReason->Insert(0, "You are banned from this server.");
        return { Action::Supersede, false };
    }

    return Action::Ignore;   // the engine decides as it would have
}

Clients leaving

ClientDisconnect is one point for both moments: pre while the player is still there (the last look at what they had), post once the engine let them go. Per-slot state goes with the player, or the next one to take the slot inherits it.

g_pSource2ModHooks->Hook(HookPoint::ClientDisconnect, HookTarget::All(), SOURCE2MOD_MEMBER(this, &MyPlugin::OnClientDisconnect));        // pre
g_pSource2ModHooks->Hook(HookPoint::ClientDisconnect, HookTarget::All(), SOURCE2MOD_MEMBER(this, &MyPlugin::OnClientDisconnectPost), true);

What the game would do: CallOriginal

Every game-function context ends with CallOriginal(): the game's function, run now with the arguments the game passed, past every hook on it — SourceHook's SH_CALL — returning what it returned. From a pre handler that is how you learn what the game would answer:

g_pSource2ModHooks->Hook(HookPoint::CanAcquire, HookTarget::All(), [](CanAcquireContext& ctx, bool post) -> HookReturn<AcquireResult>
{
    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
});

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 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_pSource2ModHooks->Hook(HookPoint::CheckJumpButtonLegacy, HookTarget::All(), [](LegacyJumpContext& ctx, bool post) -> HookReturn<void>
{
    if (sv_autobunnyhopping.Get())
        return Action::Ignore;

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

    return Action::Supersede;
});

Availability

IsAvailable(HookPointId::X) (Source2ModHooks.IsAvailable(HookPoint_X)) says whether the core's gamedata has the function on this platform. On a missing entry Hook() returns 0.

The detour of a game-function point is placed the first time any plugin hooks it and stays until the server shuts down; without handlers it only checks an empty list.

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 a typed point is not enough

  • The function is not in the set: hook it as a Function point.
  • You need to change an argument passed by value: a Function point on the same function, SetArg in pre.

Reference

  • ISource2ModHooks — Hook, HookPoint, HookTarget, HookFilter, HookReturn
  • ISource2ModHookContexts — every context and AcquireResult
  • source2mod/hooks.inc, source2mod/hookcontexts.inc — the SourcePawn side

On this page