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.
#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;
}public void OnPluginStart()
{
// Pre, every entity: scale the damage.
Source2ModHooks.Hook(HookPoint_TakeDamage, OnTakeDamage);
// Post, every pawn every tick: keep it cheap.
Source2ModHooks.Hook(HookPoint_PostThink, OnPostThink, true);
}
public Source2ModAction OnTakeDamage(TakeDamageContext ctx, bool post, int &result)
{
CTakeDamageInfo info = ctx.info;
info.m_flDamage *= 0.5; // by pointer: the original sees it
return Action_Ignore;
}
public Source2ModAction OnPostThink(PostThinkContext ctx, bool post)
{
return Action_Ignore;
}Game functions
| Group | Points | Context |
|---|---|---|
| Damage and items | TakeDamage, CanAcquire, CanUse, DropWeapon | TakeDamageContext, CanAcquireContext, CanUseContext, DropWeaponContext |
| Player | CanMove, PostThink, ProcessUsercmds, SimulateUserCommands, RunCommand | CanMoveContext, PostThinkContext, ProcessUsercmdsContext, SimulateUserCommandsContext, RunCommandContext |
| Entities | AcceptInput, Touch | AcceptInputContext, TouchContext |
| Movement services | AirMove, CanUnduck, CheckFalling, CheckParameters, CheckWater, Duck, Friction, LadderMove, MoveInit, PlayerMove, ProcessMovement, WalkMove, WaterMove | MovementContext |
AirAccelerate, GroundAccelerate, CategorizePosition, CheckVelocity, FullWalkMove, SetupMove, TryPlayerMove | one context each | |
| Jump | OnJumpLegacy, CheckJumpButtonLegacy / OnJumpModern, CheckJumpButtonModern | LegacyJumpContext / ModernJumpContext |
| Chat | PlayerChat — 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.Touchis a vtable hook onCBaseEntity's own table, so an entity class that overridesTouchis not seen.- The movement context is shared by void and bool functions:
resultmatters forCanUnduck,CheckWater,LadderMoveandMoveInitonly.
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.
| Point | The call | Context |
|---|---|---|
Spawn | CBaseEntity::Spawn(keyValues) — the keyvalues are applied here | SpawnContext |
Use | CBaseEntity::Use(info) — a player's +use, a map's Use input; info (activator, caller, use type, value) can be changed in pre | UseContext |
StartTouch, EndTouch, Blocked | CBaseEntity::StartTouch / EndTouch / Blocked(other) | EntityOtherContext |
Think | CBaseEntity::Think() — the entity's think function, each time it runs | ThinkContext |
Killed | CBaseEntity::Event_Killed(damage) -> bool — health reached 0 and m_lifeState is already "dying"; Supersede skips the death handling | KilledContext |
WeaponPickup | CCSPlayer_WeaponServices::PickupWeapon(weapon) -> 0 / 1 / 2 (refused / taken / taken as ammo) — touching or +using a weapon | WeaponPickupContext |
WeaponSwitch | CCSPlayer_WeaponServices::SwitchToWeapon(weapon, viewModelIndex) -> bool | WeaponSwitchContext |
- Hook the entity points on one entity (
HookTarget::Instance(entity), SourcePawnHookInstance: SourceMod'sSDKHook) 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. WeaponPickupandWeaponSwitchare 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, …), soFindreturns the same function for calling it.
// 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;
});// Every smoke grenade a player throws: the moment it spawns.
Source2ModHooks.HookClass(HookPoint_Spawn, "CSmokeGrenadeProjectile", OnSmokeSpawn, true);
// One button: who pressed it.
Source2ModHooks.HookInstance(HookPoint_Use, button, OnButtonUse);
// No player picks up an AWP.
Source2ModHooks.Hook(HookPoint_WeaponPickup, OnWeaponPickup);
public Source2ModAction OnSmokeSpawn(SpawnContext ctx, bool post)
{
return Action_Ignore;
}
public Source2ModAction OnButtonUse(UseContext ctx, bool post)
{
char cls[64];
if (ctx.activator != NULL_ENTITY)
{
ctx.activator.GetClassname(cls, sizeof(cls));
Source2ModApi.Log("used by %s", cls);
}
return Action_Ignore;
}
public Source2ModAction OnWeaponPickup(WeaponPickupContext ctx, bool post, int &result)
{
char cls[64];
ctx.weapon.GetClassname(cls, sizeof(cls));
if (Source2ModUtils.Equal(cls, "weapon_awp"))
{
result = 0;
return Action_Supersede;
}
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.
| Point | Context | Notes |
|---|---|---|
GameFrame | GameFrameContext: simulating, firstTick, lastTick | every tick; simulating is false while the game is not ticking (paused, hibernating) |
StartupServer | StartupServerContext: config, session, mapName | a new map's server is up |
ClientConnect | ClientConnectContext: slot, name, xuid, networkId, rejectReason, result | pre may refuse the connection — see below. Not called for bots. |
ClientConnected | ClientConnectedContext: slot, name, xuid, networkId, address, fakePlayer | the slot is taken — bots too |
ClientPutInServer | ClientPutInServerContext: slot, name, type, xuid | type 0 a player, 1 a bot |
ClientDisconnect | ClientDisconnectContext: slot, reason, name, xuid, networkId | pre while the client is still in game, post once it is gone |
ClientVoice | ClientSlotContext: slot | |
ClientSettingsChanged | ClientSlotContext: slot | |
GameServerSteamAPIActivated, GameServerSteamAPIDeactivated | EmptyContext | |
LoadEventsFromFile | LoadEventsFromFileContext: manager, filename, searchAll | a 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.
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
}Source2ModHooks.Hook(HookPoint_ClientConnect, OnClientConnect);
public Source2ModAction OnClientConnect(ClientConnectContext ctx, bool post, bool &result)
{
char xuid[24];
ctx.GetXuid(xuid, sizeof(xuid));
if (IsBanned(xuid))
{
ctx.SetRejectReason("You are banned from this server.");
result = false;
return Action_Supersede;
}
return Action_Ignore;
}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);Source2ModHooks.Hook(HookPoint_ClientDisconnect, OnClientDisconnect); // pre
Source2ModHooks.Hook(HookPoint_ClientDisconnect, 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
});public Source2ModAction OnCanAcquire(CanAcquireContext ctx, bool post, AcquireResult &result)
{
AcquireResult game = ctx.CallOriginal();
result = game == AcquireResult_Allowed && IsBlockedHere(ctx) ? AcquireResult_NotAllowedByMode : game;
return Action_Supersede; // 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 offWhen 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,
SetArgin pre.
Reference
ISource2ModHooks—Hook,HookPoint,HookTarget,HookFilter,HookReturnISource2ModHookContexts— every context andAcquireResultsource2mod/hooks.inc,source2mod/hookcontexts.inc— the SourcePawn side
Hooking
ISource2ModHooks — one call to hook a game function, an engine callback or any function by gamedata, signature or vtable slot, the same from C++ and SourcePawn.
Virtual Functions
Hooking a vtable slot with ISource2ModHooks — on one object, on every object of a class, or on an engine interface — and the vtable caveats that come with it.