|
VoltMod
C++23 framework for CS2 server plugins
|
Hook services install for their first subscriber and uninstall after the last. Each one's Available() says whether its gamedata bound, and why not.
VoltMod::Movement brackets CCSPlayer_MovementServices::RunCommand with Before and After, and both carry the decoded VoltMod::PlayerInput:
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.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:
InputHistorySent is what the client sent before the cap; it is what tells absent, invalid and capped samples apart.
Rewrite receives a mutable PlayerInput& after decoding and before every Before handler, so later handlers see its edits:
Edits change only the decoded snapshot, not the engine's CUserCmd. Use it for tests and diagnostics, not gameplay.
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.
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.
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:
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:
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.
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.
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:
<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.
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:
Reset() stays safe after the hooked object is destroyed; removal never dereferences it.LazyHook for a hook that should exist only while one of its events is subscribed.Subscription beside the handler state so their lifetimes match.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.
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.