|
VoltMod
C++23 framework for CS2 server plugins
|
This is the page that explains why. Everything a plugin author does day to day is in Writing a plugin and Running the host.
A module is a source directory and a layer in the include graph, not a namespace: every public name lives directly in VoltMod, and moving a type between modules never renames it.
| Module | Holds |
|---|---|
| Core | Signals and subscriptions, results, the load report, text, per-slot state, timing, files, logging |
| Engine | Interfaces, gamedata bindings, ConVar<T>, clock, maps, precache, console commands |
| Schema | The generated field layout and its stamp |
| Entities | Entity lookup, the Entity/Pawn/Controller wrappers, schema fields, items |
| Events | The game event service and its typed event structs |
| Messaging | Chat and center-HTML messages, chat colors |
| Players | The roster, the Policy gate, action and effect dispatch |
| Hooks | Movement, teleport, damage, weapon drops, visibility, client convars, the vote panel |
| Ui | Panorama custom_hud_layout panels and their button presses |
| Workshop | What connecting clients are told to download |
| Commands | The fluent builder, typed Args, the router |
| Menu | The menu model and Flow, drawn as center HTML; chat input for prompts |
| Http | Async HTTP and JSON REST helpers |
| Database | Async Postgres/MariaDB/SQLite and migrations |
| Unsafe | Opt-in raw hooking: HookInterface, HookVirtual, HookFunction |
| Host | The plain-data boundary between the host binary and a plugin |
| Loader | The server_valve module that starts the host and owns KHook |
| App | The composition root: Runtime, Plugin, ServiceExchange |
What each one may include:
voltmod lint (cli/voltmod/framework/layering.py) enforces that block, rejects upward edges and reports cycles. A module's own Api.hpp is exempt. Only Commands and App may name Runtime, and header-only templates such as Flow<TState> and PerSlot<T> avoid the composition root so consumer translation units stay narrow.
Where a lower module needs something the host resolved, it takes an injected callable instead of an upward include: Bindings::Bind takes a GameDataLookup, and App adapts IPluginGameData to it in the GameData load check.
These are source layers, not link units. The framework ships VoltMod::Sdk and the optional VoltMod::Database; Host is compiled into the host binary only, and Loader into the loader.
One process, one host, one set of hooks. The host owns everything that can only exist once - the engine hooks, the gamedata scan, the schema verification, the volt command, the table of registered command names, the table of published interfaces and the list of workshop addons clients must download (IPluginAddons) - and hands each plugin a typed view of it (IPluginContext). A plugin is an ordinary library the host opens with LoadLibrary or dlopen.
Keeping the hooks in the host is what makes several plugins on one server cheap: they share one frame hook and one chat hook instead of each hooking the engine, and a reload takes down one plugin rather than the whole stack.
server_valve before server and finds the loader through Game csgo/addons/voltmod, directly above Game csgo in gameinfo.gi. The loader loads the next server on the Game lines (Metamod's where installed, else the game's) and forwards every interface request to it.ISource2Server::Init, before the game's own Init, the loader starts the host, so Metamod and its plugins start after the framework. The loader owns the process's one KHook.addons/voltmod/gamedata/gamedata.jsonc once for the process. A signature a game update broke is logged here and nowhere else.volt command name.addons/voltmod/plugins/*/plugin.json, refuses the plugins whose required dependencies are not there with one ‘Refusing ’<name>': <reason>line each, and loads the rest alphabetically.For each plugin it opens the library, resolvesVoltMod_Plugin, checks the descriptor's VoltMod version and itsBuildStamp/Load/Unload/Statusfields, opens a host view under the plugin's name and log tag, then callsLoad.Inside the plugin, the internal module seeds its hook dispatch pointer, installs the log handler, and compares the schema stamp, refusing a plugin built against a different layout. It then resolves the engine interfaces and binds gamedata, and builds theRuntime: each service does its setup in its constructor, and the runtime checks them into the load report.The module constructs the derivedPlugin, whose members load settings and subscribe as they are built. A required step that failed so far refuses the plugin here, beforeLoad. Otherwise it subscribes to host events and callsLoad. AfalsefromLoadreturns the first required step's reason to the host, which logs it as the refusal, destroys the plugin, and frees its library.The host logsN of M installed plugin(s) loaded.`Unload runs in reverse: the plugin's commands, the plugin object, its host-event subscriptions, then the Runtime. Only once nothing of the plugin is still running does the host free the library - every hook thunk and subscription closure it installed is code inside it.
Each plugin carries its own copy of the static SDK and its own allocator, so the host/plugin boundary is a real ABI boundary:
include/VoltMod/Host/ holds plain structs and pure-virtual interfaces; nothing owning, such as std::string, appears in a signature. Text crosses as std::string_view, valid for the call only.std::string_view out differently, and nothing at load time catches that.noexcept and logs what it caught.PluginDescriptor::VoltModVersion must equal the host's version, and the plugin's schema layout stamp must equal the host's. Either mismatch refuses the plugin and says to rebuild it.The same rules apply between two plugins, which is why VoltMod::ServiceExchange interfaces carry a version in their name; see Running the host.
VoltMod::Runtime is the service container for one load cycle: created on load, destroyed on unload, with most services as direct members in dependency order. Each service is ready when it is built; there is no second Initialize step. Your App holds what the plugin owns for that same cycle, its members also in dependency order with the settings first, and is destroyed first, so its subscriptions unregister while the services they reference are still alive. That pair is what makes volt reload start clean.
Schema offsets are not a service. voltmod framework schemagen bakes them into the generated accessors at build time, so reading m_iHealth needs nothing threaded through a constructor.
Three rules follow from the single-cycle model:
GameFrame hook replays them through VoltMod::Scheduler, so a callback never races game code.IPermissions, and one gate, Policy::Authorize, applies them to commands, targeting and menu rows. Anything declaring a permission is denied while nothing publishes it. See Players, targeting, and actions.File-static state is reserved for engine callbacks that cannot carry user data and for process-wide values such as the log handler and the base directory. The service that owns a callback also sets and clears its static bridge.
Fixed-signature signals are public VoltMod::Event members subscribed with +=; game events go through typed VoltMod::GameEvents::On.
Both return a move-only, [[nodiscard]] VoltMod::Subscription that unregisters on destruction. HookInterface gives engine hooks the same lifetime, which is why cleanup is a matter of member order rather than an unload routine.
An expensive event source takes an EventLifecycle: the first subscriber installs it, the last removal uninstalls it, and if the hook cannot resolve, the subscription comes back empty with the reason logged. An engine hook behind one or more events is a LazyHook: it counts subscribers across all of them, installs the hook once, logs why an install failed, and removes it with the last handler.
Operations that can fail meaningfully return Result<T> or VoltMod::Status, an std::expected over VoltMod::Error: a coarse ErrorCode, log text in Detail, and a translation key in Key when a player is owed a reply.
Index a fixed-size, MaxPlayers-sized array only after VoltMod::IsValidSlot, and prefer PerSlot<T>. An unchecked [slot] into a service the Runtime owns by value corrupts a neighbouring member instead of failing.