VoltMod
C++23 framework for CS2 server plugins
Loading...
Searching...
No Matches
Movement, teleports, damage, weapon drops, and server commands

Hook services install for their first subscriber and uninstall after the last. Each one's Available() says whether its gamedata bound, and why not.

Movement

VoltMod::Movement brackets CCSPlayer_MovementServices::RunCommand with Before and After, and both carry the decoded VoltMod::PlayerInput:

// Keep each Subscription beside the state its handler captures.
_before = runtime.Movement.Before += [this](int slot, const VoltMod::PlayerInput& cmd) {
if (!cmd.Valid)
return; // null usercmd, or the CSGOUserCmdPB offset did not bind
// cmd.ViewYaw, cmd.MouseDx, cmd.ButtonsHeld, cmd.Subticks(), cmd.InputHistory(), ...
};
_after = runtime.Movement.After += [this](int slot, const VoltMod::PlayerInput&) { /* restore */ };
Protobuf-free snapshot of the CUserCmd handed to CPlayer_MovementServices::RunCommand,...

The host hooks RunCommand once for every plugin and decodes each command once, only while some plugin listens; a command whose owner is not a player is not raised. Rewrite edits this plugin's own copy. Unresolved gamedata returns an empty Subscription and logs the reason; a wrong RunCommand slot can crash, so re-verify it after a CS2 update.

Fields worth knowing:

  • CommandNumber comes from the CUserCmdBase::cmdNum offset, because live clients leave the protobuf legacy_command_number at zero. Gaps mean lost, reordered or synthesized commands.
  • HasViewAngles == false means the angle fields hold defaults, not measurements.
  • ViewRoll is viewangles.z; mouse input drives only pitch and yaw.

Input history and the cap

InputHistory() holds per-shot angles and claimed targets. Attack indices address the client's full input list, but only MaxInputHistory (16) entries are kept, so use SampleAt and never clamp an out-of-range index:

const int index = cmd.Attack1StartHistoryIndex;
if (const auto shot = cmd.SampleAt(index))
Compare(shot->ViewYaw, cmd.ViewYaw); // the entry is present
else if (index >= cmd.InputHistorySent)
/* the client named an entry it never sent: a malformed command */;
else if (index >= 0)
/* a shot happened but its angles were capped away, so no verdict */;
// otherwise the index is -1: no attack started this command

InputHistorySent is what the client sent before the cap; it is what tells absent, invalid and capped samples apart.

Rewrite

Rewrite receives a mutable PlayerInput& after decoding and before every Before handler, so later handlers see its edits:

_rewrite = runtime.Movement.Rewrite += [](int slot, VoltMod::PlayerInput& cmd) {
cmd.ViewYaw += 90.0f; // every downstream reader now sees the rotated view
};

Edits change only the decoded snapshot, not the engine's CUserCmd. Use it for tests and diagnostics, not gameplay.

Teleport

VoltMod::Teleport raises Before(slot) when a player pawn is about to move through CBaseEntity::Teleport, so consumers can ignore the resulting discontinuity in motion data. The service keeps no history; store your own window.

// Subscribing is what installs the hook. _lastTeleport, a PerSlot<float> constructed with
// runtime.Slots, clears a stamp when the seat changes hands.
_teleports = runtime.Teleport.Before += [this](int slot) { _lastTeleport[slot] = _rt.Clock.Time(); };

The first subscription hooks the pawn class vtable, so every pawn is covered and respawns need no rebinding. Spawning also raises the event, so filter it out if you only want mid-life teleports. A pawn without a player raises nothing. The hook spans map changes, but runtime.Clock restarts with the map.

Damage

VoltMod::Damage hooks CBaseEntity::TakeDamageOld, which every entity's damage passes through: players, props, and hits dealt by Apply. Before receives a VoltMod::DamageHit before the engine applies it:

_damage = runtime.Damage.Before += [this](VoltMod::DamageHit& hit) {
if (!IsStructure(hit.Victim.Ref()))
return;
hit.Blocked = true; // the engine deals nothing and fires no event
Wear(hit.Victim.Ref(), hit.Attacker, hit.Amount);
};

Edits to hit.Amount and hit.Type reach the engine; Attacker and Inflictor are const.

Apply deals damage through the same engine path, so death, the kill feed and player_death credit the attacker as if their own weapon had hit:

runtime.Damage.Apply(target, {.Attacker = owner.Ref(), // credited in the kill feed
.Inflictor = turret, // empty means the attacker
.Amount = 25.0f,
constexpr uint32_t DamageBullet
Definition Damage.hpp:24

The engine drops a hit with no inflictor, so an empty Inflictor falls back to the attacker. The hit lands at the victim's origin, pushed away from the inflictor. player_death names the attacker's active weapon. Both need the CBaseEntity::TakeDamageOld and CTakeDamageInfo::CTakeDamageInfo signatures; Available() says which one did not bind.

Damaging props

Bullets reach Before for any prop with collision, with the prop as hit.Victim. A prop_dynamic spawned with a precached model and solid 6 is enough. Bullets that hit the world arrive too, with worldent (index 0) as the victim.

The engine keeps no health on a prop_dynamic: a health keyvalue and SetMaxHealth change nothing. Keep the prop's health in the plugin and set hit.Blocked. A prop_physics_override with spawnflags 8 (motion disabled) and a health keyvalue does lose health, to bullets and to Apply, and the engine removes it at zero.

Weapon drops

VoltMod::WeaponDrop hooks CCSPlayer_WeaponServices::DropWeapon, the game's handler for a player dropping a weapon themselves. The drop key (G) arrives here, never as a drop command. The hook runs before the game's own checks, so it sees the key even while mp_death_drop_gun 0 or mp_drop_knife_enable 0 keep the weapon in hand. That makes G usable as a plugin key:

_drops = runtime.WeaponDrop.Before += [this](VoltMod::WeaponDropRequest& drop) {
if (drop.Swapping)
return; // picking up a weapon pushed this one out
drop.Blocked = true; // the weapon stays in hand
ToggleShop(drop.Slot);
};

Hooking a vfunc the framework does not cover

<VoltMod/Unsafe/Hook.hpp> has three entry points. Each yields the VoltMod::Subscription that removes the hook when dropped; the two gamedata-bound ones wrap it in a Result, because an unbound slot or signature is an error rather than a silent no-op.

Entry point Hooks Use for
HookInterface(&Iface::Method, instance, before[, after]) that one instance a named SDK interface you hold a pointer to
HookVirtual(name, bindings.Member, before[, after]) calls through the bound class vtable a virtual function located by gamedata
HookFunction(name, bindings.Member, before[, after]) the function where its code starts a non-virtual function

Prefer HookVirtual for anything virtual. A hook placed where the code starts catches every caller, and the compiler often folds many classes onto one body - a slot returning false can be the same code in hundreds of unrelated classes.

A handler is any callable taking the hooked object first, as a reference to the class the slot dispatches on: an engine interface, or one of the Engine* stand-ins in EngineTypes.hpp for a class whose layout the SDK omits. A stand-in is an identity, not a layout, so never dereference one. A before-handler returns VoltMod::HookResult, or nothing when it only observes; an after-handler takes the same arguments and returns nothing. Name each handler in a local and keep longer logic in a static function at the top of the .cpp, so the hook call itself stays one line.

// void* CPlayer_MovementServices::RunCommand(CUserCmd*)
const auto onCommand = [this](VoltMod::EngineMovementServices& services, void* userCmd) {
Record(&services, userCmd);
};
auto hook = VoltMod::HookVirtual("MyPlugin RunCommand", _rt.Unsafe.Bindings.RunCommand, onCommand);
if (!hook)
{
VoltMod::Log::Warn("command watch off: {}", hook.error().Detail);
return;
}
_hook = std::move(*hook);
void Warn(std::format_string< Args... > fmt, Args &&... args)
Definition Log.hpp:88
Result< Subscription > HookVirtual(std::string_view name, const VirtualFn< Ret(Object *, Args...)> &function, Before &&before, After &&after=nullptr)
Hook a gamedata-bound virtual function on objects sharing its class vtable.
Definition Hook.hpp:168

A gamedata base counts the slot in a base class's own table, so a function on a secondary base is hooked there and the handler receives that base. An unbound slot is reported as an error rather than installing nothing.

What the hook layer does and does not do:

  • Both handlers ride one hook, so there is no half-installed pair to unwind.
  • Reset() stays safe after the hooked object is destroyed; removal never dereferences it.
  • The object type is checked at compile time, so a pawn cannot be passed where a client belongs.
  • Slot correctness is still yours to verify; see Gamedata and schema.
  • Use a LazyHook for a hook that should exist only while one of its events is subscribed.
  • Keep the Subscription beside the handler state so their lifetimes match.

ServerCommand

VoltMod::ServerCommand owns a tier1 ConCommand. Construction registers it, destruction unregisters it, and the handler runs on the game thread. Construct it only while the plugin is loaded (ICvar must be live), typically as a manager member so unload cleans it up.

class MyManager
{
VoltMod::ServerCommand _cmd{"myplugin_do", "Do the thing: myplugin_do <steamid64>",
[this](const CCommand& args, int slot) { /* args.ArgC(), args.Arg(1), ... */ }};
};
RAII server console command: registers a tier1 ConCommand on construction and unregisters on destruct...

Call one with runtime.ConVars.ExecuteServerCommand("myplugin_do 765..."); the engine reports an unknown command when no provider is loaded. A player cannot run it from their own console unless the constructor's last argument, playersCanRun, is true; the handler then runs for players too, with their slot (-1 for the server). Server commands are for console, RCON, cfg files and loose automation. For a typed contract between two plugins publish a versioned interface through runtime.Exchange instead, and never transfer ownership or exceptions across module boundaries.