Plugin Lifecycle
When plugins are loaded and unloaded, what the listener callbacks tell you, what Source2Mod cleans up for you, and how hot reload works.
This page describes what the core does with a plugin from the moment it finds the .smx file to the moment it closes it — so you know what is safe to do in Load(), what you must undo in Unload(), and which callbacks to rely on.
Loading at server start
Source2Mod is a Metamod:Source plugin that must be loaded when the server starts (a late meta load is refused). While Metamod loads it, the core:
- fetches the engine interfaces, reads
configs/core.jsonand installs the crash handler; - loads every
.jsonfile ingamedata/and resolves its own addresses; - registers its commands and hooks;
- loads every
.smxinplugins/— unlessPluginAutoLoadEnabledisfalse; - calls
OnAllSource2ModPluginsLoaded()on every plugin's listeners; - starts the hot reload watcher (Linux, when
PluginHotReloadEnabledis on).
Later, when Metamod has loaded all of its plugins, the core forwards that as OnAllMetamodPluginsLoaded().
This happens before the first map is loaded. In Load() at server start there is no map, no entity system content and no player — anything that needs those belongs in OnLevelInit() or a game event.
Load order
Plugins are loaded in the order the filesystem lists the plugins/ folder, which is not guaranteed to be alphabetical and can differ between machines. Do not depend on another plugin in Load(). Wait for OnAllSource2ModPluginsLoaded() to look up interfaces other plugins expose:
void MyPlugin::OnAllSource2ModPluginsLoaded()
{
int ret = SOURCE2MOD_IFACE_FAILED;
GET_SOURCE2MOD_IFACE(m_pOther, IOtherPlugin, "IOtherPlugin001", ret);
if (ret != SOURCE2MOD_IFACE_OK)
SOURCE2MOD_LOG(this, "IOtherPlugin is not available, feature X is off.\n");
}The other plugin answers that query in its own listener's OnSource2ModQuery().
What happens in a load
For each .smx the core:
- opens the library and calls its
CreateInterfaceexport forISource2ModPlugin(whichSOURCE2MOD_EXPOSEprovides); - compares
GetApiVersion()with the core'sSOURCE2MOD_PLAPI_VERSION— a plugin built against another API than the core's, newer or older, is refused and has to be rebuilt against the matching SDK (see Compatibility); - assigns the next plugin id and adds the plugin to its list;
- calls
Load(id, api, error, maxlen, late); - on success, calls
OnPluginLoad(newId, name)on every listener of every loaded plugin — including the new plugin's own, if it added one inLoad().
If Load() returns false, the core removes everything the plugin registered so far, closes the library and logs Plugin <name> refused to load: <error>.
The late flag
late is true only when the core is loading the plugin as part of a hot reload. A plugin loaded with s2m load or s2m refresh on a running server receives false, even though a map may already be running. If your plugin needs to initialise per-player or per-map state, do not rely on late alone — check the actual state (for example, loop over player slots) instead.
Plugin ids
Ids start at 1 and increase with every load; they are never reused while the server runs. A hot-reloaded or re-loaded plugin gets a new id. The id is what s2m list, s2m info and s2m unload use, and what OnPluginLoad / OnPluginUnload report.
Your own id is g_PluginID after SOURCE2MOD_SAVEVARS().
Calls that take a handler — RegisterConCommand, HookGameEvent, AddTimer, Get, the game hooks, … — have no id argument. Every handler is a Source2ModCallback (a free or static function, an object and a method, or a lambda), and the core reads the owner off the handler itself: it maps a code address of the function, method or lambda to the plugin library it lives in. Entity listeners work the same way through the listener object's class. So the registration macros (REGISTER_CON_COMMAND, HOOK_GAME_EVENT, ADD_TIMER, …) just forward their arguments.
g_PluginID is still passed to the calls that have no handler to read it from: PluginConfigDirectory, CreateConnection / CreateServerless, CreateSound, the transmit HookEntity, the scripts' LoadAsset / RunSource / RunFile, and opening a menu (OpenCenterHtmlMenu, OpenMenu). Their macros (PLUGIN_CONFIG_DIR(), MYSQL_CREATE_CONNECTION, TRANSMIT_HOOK_ENTITY, SCRIPTS_RUN_FILE, OPEN_CENTER_HTML_MENU, …) fill it in for you.
A plugin that wraps a handler another plugin handed it (a command framework, a menu library) keeps that other plugin as the owner with Source2ModCallback::OwnedAs():
ChatHandler wrapped = [handler](auto& ctx, auto& args, bool post) { if (Allowed(ctx)) handler(ctx, args, post); };
g_pSource2ModCommands->RegisterConCommand(name, std::move(wrapped.OwnedAs(handler)));The registration then goes when the plugin that made handler unloads.
Ownership: what Source2Mod cleans up
Everything registered through a Source2Mod interface belongs to the plugin whose handler (or id) it was registered with. On unload (and on a failed load) the core removes, for that plugin:
- console commands, command listeners and chat listeners;
- game event hooks;
- game hook handlers;
- ConVar change callbacks;
- network message hooks;
- custom HUD callbacks;
- sound hooks and sound state;
- transmit rules;
- scripts (their
point_scriptentities) and script message hooks; - timers and next-frame tasks;
- pending HTTP requests;
- MySQL connections;
- entity listeners;
- open menus (a HUD menu is hidden for the player as well);
- the plugin's
ISource2ModListeners.
Calling UnregisterConCommand, UNHOOK_GAME_EVENT, KILL_TIMER and the like yourself is only needed to turn a feature off while the plugin stays loaded. Every Register* / Hook* that takes a handler returns a Source2ModHookId; the matching Unregister* / Unhook* takes that id, or the same function or object-and-method again (a lambda has no identity, so it only goes by id).
What you must undo yourself
Some things live in your own library and Source2Mod cannot reach them:
- KHook hooks. Call
KHOOK_DESTRUCT()inUnload(). A hook left installed after the library is closed jumps into unmapped memory on its next call. Every hook comes out right there, synchronously — which is safe only because the core never unloads a plugin from inside a function the plugin might hook; see Unloading. CConVars you declared. CallConVar_Unregister()inUnload(). It only walks the list of the library that calls it, so the core cannot do it for you.- Game systems you registered on the engine's factory list.
- Threads, sockets, files and any other resource you created yourself.
bool MyPlugin::Unload(char* error, size_t maxlen)
{
KHOOK_DESTRUCT();
ConVar_Unregister();
return true;
}Unloading
A plugin is unloaded by s2m unload <id>, by a hot reload, or when Source2Mod itself unloads (server shutdown). In each case, for that plugin:
OnPluginUnload(id, name)is called on every listener of every plugin — including the plugin being unloaded;Unload(error, maxlen)is called;- its listeners and everything it owns are removed (see above);
- the library is closed on the next frame, so that no stack frame still points into it.
Never from inside a hooked function
A KHook detour holds the hooked function's lock for the whole pre → original → post run, and taking a hook on that function out from inside it waits forever. So the core never runs an unload from a game callback — GameFrame is the function most plugins hook. A s2m unload typed into the console runs right away: the command buffer is executed between frames, outside every hooked function. A hot reload, and an unload a plugin requests from code, are queued and pushed into that same command buffer (s2m _pending), and run on its next pass. The one function a plugin cannot be unloaded from inside is ICvar::DispatchConCommand, which is how the console command itself arrives.
Source2Mod's own unload (meta unload) arrives through that hook too; the core supersedes the command, drops its DispatchConCommand hook at the end of the frame and re-issues the unload from the command buffer.
The return value of Unload() and anything written to error are ignored — a plugin cannot refuse to be unloaded.
At server shutdown every plugin goes through steps 1–3 in turn, and all libraries are closed together at the end, since there is no next frame.
Listener callbacks
Implement ISource2ModListener and register it with api->AddListener(this, listener) in Load(). All methods have empty defaults.
| Callback | When |
|---|---|
OnPluginLoad(PluginId id, const char* name) | A s2m plugin (.smx) finished loading. id is a s2m plugin id, name its GetName(). |
OnPluginUnload(PluginId id, const char* name) | A Source2Mod plugin is about to be unloaded. |
OnMetamodPluginLoad(PluginId id) | A Metamod:Source plugin was loaded. id is Metamod's id, a different numbering. |
OnMetamodPluginUnload(PluginId id) | A Metamod:Source plugin was unloaded. |
OnAllSource2ModPluginsLoaded() | After the start-up load of plugins/, and after a s2m refresh that loaded at least one plugin. Called on every plugin's listeners each time. Not called for s2m load or a hot reload. |
OnAllMetamodPluginsLoaded() | Metamod finished loading all its plugins. |
OnLevelInit(mapName, mapEntities, oldLevel, landmarkName, loadGame, background) | A map is starting. |
OnLevelShutdown() | A map is shutting down. |
OnGameFrame(simulating, firstTick, lastTick) | Once a frame, after the engine's GameFrame and Source2Mod's own per-frame work. Plugin API 2. |
OnStartupServer(config, session, mapName) | A new server session; the new map's entity system is up. API 2. |
OnClientPutInServer(slot, name, type, xuid), OnClientVoice(slot), OnClientSettingsChanged(slot), OnClientDisconnect(slot, reason, name, xuid, networkId) | After the engine's IServerGameClients call of the same name. API 2. |
OnGameServerSteamAPIActivated(), OnGameServerSteamAPIDeactivated() | The Steam API came up / is about to go. API 2. |
OnLoadEventsFromFile(manager, filename, searchAll) | After IGameEventManager2::LoadEventsFromFile; the place to keep the manager or register a .gameevents file of your own. API 2. |
OnClientAuthorized(slot, steamId) | Steam validated the player; from now on their permissions are looked up under steamId. Not called for bots. API 3. |
OnClientAuthorizeFailed(slot, steamId) | Steam refused the player's ticket; steamId is the one they claimed, and it no longer counts for permissions. API 3. |
OnPermissionsChanged(steamId) | What this SteamID holds may have changed. 0 when a change can touch anybody (a group edited, permissions.json reloaded, a plugin's grants dropped). API 3. |
OnSource2ModQuery(const char* iface, int* ret) | Another s2m plugin asked for an interface. Return a pointer and set *ret = SOURCE2MOD_IFACE_OK to answer. |
OnMetamodQuery(const char* iface, int* ret) | A Metamod plugin asked for an interface. Defaults to answering like OnSource2ModQuery(); return nullptr to keep an interface for s2m plugins only. |
When PluginAutoLoadEnabled is false, nothing is loaded at start and OnAllSource2ModPluginsLoaded() first fires when s2m refresh loads the plugins.
See the ISource2ModPlugin reference for the full declarations.
Globals in other files
SOURCE2MOD_EXPOSE(name, var) defines Source2Mod globals once. Every other .cpp that uses g_Source2ModAPI, g_PluginID, a g_pSource2Mod* pointer or a macro built on them needs the extern declarations:
#include "source2mod/ISource2ModPlugin.h"
#include "source2mod/ISource2ModApi.h"
SOURCE2MOD_GLOBALVARS();
void Greeter::Setup()
{
REGISTER_CON_COMMAND("greet", [](const Source2ModCommandContext& context, const Source2ModCommandArgs& args, bool post)
{
SOURCE2MOD_CONPRINTF("Greetings.\n");
});
}The sample plugin puts SOURCE2MOD_GLOBALVARS(); in its shared plugin.h, so every file that includes it has the declarations.
Hot reload
On Linux, with PluginHotReloadEnabled set in core.json, the core watches plugins/ for .smx files that are written and closed (inotify IN_CLOSE_WRITE). When that happens to a plugin that is currently loaded, on the next frame the core:
- unloads it exactly as
s2m unloaddoes; - on the following frame closes the old library and loads the file again, with
late = trueand a new plugin id.
Things to know:
- Only plugins that are already loaded are reloaded. A new
.smxdropped intoplugins/is not picked up automatically — runs2m refresh. - Copying the file (
cp,scp) triggers it. Moving or renaming a file into the folder (mv,rsync's default temp-file-and-rename) does not, because that is not a close-after-write. - The setting is read at start; changing it needs a server restart.
- There is no hot reload on Windows. Use
s2m unload <id>ands2m load <name>instead.
Because the plugin gets a fresh id and its old registrations are gone, a plugin that sets itself up completely in Load() needs nothing special to support hot reload. Use late to rebuild state the start-up path would have received from callbacks, such as the current map.
C++ for C# developers
The part of C++ a plugin actually needs, explained from C# — pointers and lifetimes, strings, lambdas, collections, casting, headers, and what a crash tells you.
Compatibility
What a core update means for a plugin — the stable and raw tiers, interface revisions and the compat adapters, the plugin API version, the KHook check, and what s2m list shows.