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.
A virtual function is hooked on its vtable: one slot, replaced once, and the
core compares the object before a handler runs. Which objects a handler sees
is the target: one object (Instance), every object of
a class or one derived from it (Class), or every call (All).
Several typed points are virtuals already —
Touch, CanUse, DropWeapon, RunCommand. Anything else is a
Function point on an ISource2ModFunction that
names the slot.
Which slot
| C++ | SourcePawn | The slot |
|---|---|---|
Find(name) on an entry with "offsets" and a "function" object | Find(name) | from gamedata, per platform, with its types |
FromGameConfig(owner, name, types) on an entry with "offsets" | FromGameConfig(name, returnType) + AddParam | from gamedata, types given here |
FromVirtualTable(owner, module, className, index, types, baseName) | FromVirtualTable(module, vtableName, vtableIndex, returnType, baseName) | one class's vtable, by RTTI name in a module |
FromVirtual(owner, index, types) | FromVirtual(vtableIndex, returnType) | whatever object it is hooked on — needs an Instance or Class target |
A slot from gamedata follows the core's (or your) gamedata through game updates. Offsets differ between Windows and Linux, and gamedata keeps one per platform — see Gamedata.
In C++, KHook::GetVtableIndex(&Class::Method) reads the index out of a member
function pointer, so an engine interface's slot needs nothing written down.
The class declaration has to match the game's vtable slot for slot;
headers carried over from older games often do not, so check the index
against the binary before you trust one, and prefer a gamedata offset for game
classes.
On one object
An engine interface you hold a pointer to: FromVirtual with the slot, and
the object as an Instance target. This is cpp_sample swallowing one
client command before the engine sees it:
// void ISource2GameClients::ClientCommand(CPlayerSlot slot, const CCommand& args)
static const Source2ModType kClientCommand[] = { Source2ModType::Pointer, Source2ModType::Int32, Source2ModType::Command };
ISource2ModFunction* pClientCommand = g_pSource2ModFunctions->FromVirtual(g_PluginID,
KHook::GetVtableIndex(&ISource2GameClients::ClientCommand),
FunctionTypes{ Source2ModType::Void, kClientCommand, 3, true });
g_pSource2ModHooks->Hook(HookPoint::Function(pClientCommand), HookTarget::Instance(g_pSource2GameClients),
SOURCE2MOD_MEMBER(this, &MyPlugin::OnClientCommand));
HookReturn<void> MyPlugin::OnClientCommand(FunctionContext& ctx, bool post)
{
const CCommand* pArgs = ctx.GetArg<const CCommand*>(2); // 0 this, 1 the slot, 2 the command
if (pArgs && !V_strcmp(pArgs->Arg(0), "blocked_command"))
return Action::Supersede; // the engine never sees it
return Action::Ignore;
}void HookClientCommand()
{
// The slot is in the core's gamedata, the same on both platforms.
int index = Source2ModGameConfig.GetOffset("ISource2GameClients::ClientCommand");
if (index < 0)
return;
Source2ModFunction command = Source2ModFunction.FromVirtualTable("server", "CSource2GameClients", index, Source2ModType_Void);
if (command == null)
return;
command.AddParam(Source2ModType_Pointer); // 0: this
command.AddParam(Source2ModType_Int32); // 1: CPlayerSlot
command.AddParam(Source2ModType_Command); // 2: const CCommand &
Source2ModHooks.HookFunction(command, Hook_ClientCommand);
}
public Source2ModAction Hook_ClientCommand(FunctionContext ctx, bool post)
{
Source2ModCommandArgs args = ctx.GetCommandArgs(2);
char command[64];
args.Arg(0, command, sizeof(command));
if (Source2ModUtils.Equal(command, "blocked_command"))
return Action_Supersede; // the engine never sees it
return Action_Ignore;
}An entity works the same way: HookTarget::Instance(pPawn)
(HookFunctionInstance(fn, pawn, …)). A hook on one entity goes with the
entity.
On a class
HookTarget::Class("CCSPlayerPawn") (HookFunctionClass) hooks every object
of that C++ class or one derived from it, by RTTI. A slot from a gamedata
entry hooked on a class:
ISource2ModFunction* pCollision = g_pSource2ModFunctions->Find("CBaseEntity::CollisionRulesChanged");
g_pSource2ModHooks->Hook(HookPoint::Function(pCollision), HookTarget::Class("CCSPlayerPawn"), OnPawnCollisionRulesChanged);Source2ModFunction collision = Source2ModFunction.Find("CBaseEntity::CollisionRulesChanged");
Source2ModHooks.HookFunctionClass(collision, "CCSPlayerPawn", OnPawnCollisionRulesChanged);FromVirtualTable with an All() target hooks that one class's table: every
object that uses it.
A vtable hook only sees calls that go through the vtable. Inside one
library the compiler often knows the exact class and calls the function
directly, and such a call never reaches your hook. Calls from another
library — the engine calling into libserver, say — always go through the
vtable. If a virtual hook stays silent, look at how the function is actually
called before blaming the index — or hook the implementation by signature.
Secondary vtables
A class with several bases has several vtables: the primary one, shared
with the first base, and one more for every base further in. A class name
alone finds the primary one. For an interface that is not the first base, name
the base too — FromVirtualTable(..., baseName) reads where the base sits out
of the RTTI and uses that table:
// IHLTVDirector is CCSHLTVDirector's third base; `types` describes OnRecordingStarted.
ISource2ModFunction* pRecordingStarted = g_pSource2ModFunctions->FromVirtualTable(g_PluginID, "server", "CCSHLTVDirector",
KHook::GetVtableIndex(&IHLTVDirector::OnRecordingStarted), types, "IHLTVDirector");The same lookups are on ISource2ModModule for your own use:
GetVirtualTableByBase(name, baseName), and
GetVirtualTableByOffset(name, offset) when you know the byte offset instead.
An offset moves whenever the class is laid out anew, so prefer the base. Only
non-virtual bases can be looked up.
Two other ways around it:
- a Function point on the implementation, by signature;
- an Instance target on the interface pointer itself, when you can get it. That pointer already points at the secondary table.
Calling the original
In a handler, ctx.CallOriginal() runs the function past every hook on it.
Outside one, fn->CallOriginal<Sig>(pThis, ...) (fn.CallOriginal(...))
does the same — see Calling game functions.
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.
Function Hooks
HookPoint::Function — hooking any game function by gamedata entry, address, signature, symbol or vtable slot through ISource2ModFunctions; SourceMod's DHooks.