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++SourcePawnThe slot
Find(name) on an entry with "offsets" and a "function" objectFind(name)from gamedata, per platform, with its types
FromGameConfig(owner, name, types) on an entry with "offsets"FromGameConfig(name, returnType) + AddParamfrom 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:

plugin.cpp
// 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;
}

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);

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.

On this page