Command Listeners

Running code before or after commands the game, the engine or other plugins own — and blocking them.

A listener is a callback on a command that already exists: jointeam, say, kill, an engine command, another plugin's command. It runs around the command rather than replacing it, and a pre listener decides whether the command runs at all.

plugin.cpp
#include "source2mod/ISource2ModCommands.h"

// No spectators: refuse "jointeam 1".
REGISTER_CON_LISTENER("jointeam", [](const Source2ModCommandContext& context, const Source2ModCommandArgs& args, bool post) -> Action
{
    if (args.ArgC() >= 2 && atoi(args.Arg(1)) == 1)
    {
        Reply(context, "Spectating is disabled on this server.");
        return Action::Supersede;
    }

    return Action::Ignore;
}, false);

(Reply is the helper from the Commands page.)

The API

ISource2ModCommands.h
using CommandHandler = Source2ModCallback<Action(const Source2ModCommandContext&, const Source2ModCommandArgs&, bool post)>;

virtual Source2ModHookId RegisterConListener(const char* pchName, CommandHandler handler, bool post) = 0;
virtual bool UnregisterConListener(const char* pchName, const CommandHandler& handler, bool post) = 0;
virtual bool UnregisterConListener(Source2ModHookId id) = 0;

#define REGISTER_CON_LISTENER(pchName, handler, post) g_pSource2ModCommands->RegisterConListener(pchName, handler, post)
  • pchName — the command to listen to, in lower case (the dispatcher lower-cases what was typed before it looks for listeners).
  • post — false to run before the command, true to run after it. Register twice if you need both.
  • The handler is a lambda, a function or an object and a method (SOURCE2MOD_MEMBER(this, &MyPlugin::OnJoinTeam)); the core reads which plugin it belongs to off the handler, so there is no plugin id to pass. It gets the same Source2ModCommandContext and Source2ModCommandArgs a command handler gets — see arguments and the caller.
  • In a script it is Source2ModCommands.RegisterConListener(name, handler, post) with a named public function that returns a Source2ModAction.

A plugin can register several listeners on the same command. They are called in registration order. RegisterConListener returns a Source2ModHookId; g_pSource2ModCommands->UnregisterConListener(id) drops that one listener, and UnregisterConListener(name, handler, post) drops a function or object-and-method listener passed again (a lambda only goes by id). A script unregisters by id only. Unloading the plugin drops all of them.

// in Load()
REGISTER_CON_LISTENER("say", OnPlayerChat, false);

// later, to turn the filter off
g_pSource2ModCommands->UnregisterConListener("say", OnPlayerChat, false);

Return values

Listeners return an Action (in a script Action_Ignore, Action_Override, Action_Supersede):

ReturnPre (post == false)Post (post == true)
Action::IgnoreNothing changes; the next listener runs, then the command.Nothing.
Action::OverrideThe command still runs, but the remaining pre listeners are skipped — and for a console command the post listeners too.Nothing.
Action::SupersedeThe command does not run. The remaining listeners — pre and post — are skipped.The command already ran; only the remaining post listeners are skipped.

In practice: return Ignore when you only watch, Supersede when you block, and avoid Override — for a command there is no return value to override, and it silently cuts the other listeners off.

A Supersede from your listener also stops every listener registered after it, including other plugins' and the handler of a command registered with REGISTER_CON_COMMAND on the same name. Block only what you mean to block.

Pre and post together

plugin.cpp
REGISTER_CON_LISTENER("jointeam", [](const Source2ModCommandContext& context, const Source2ModCommandArgs& args, bool post) -> Action
{
    // Pre: decide.
    return Action::Ignore;
}, false);

REGISTER_CON_LISTENER("jointeam", [](const Source2ModCommandContext& context, const Source2ModCommandArgs& args, bool post) -> Action
{
    // Post: the team change has been processed -- react to it.
    SOURCE2MOD_LOG(&g_Plugin, "Slot %d ran \"%s\"\n", context.GetPlayerSlot().Get(), args.GetCommandString());
    return Action::Ignore;
}, true);

Which commands can be listened to

The core hooks the engine's command dispatch, so a listener sees:

  • every console command run from the server console or RCON;
  • console commands a client runs that go through the engine's command system;
  • jointeam, which the game handles as a raw client command — the core routes it to listeners separately;
  • chat: say and say_team (see below), and every chat command typed with a chat trigger.

A client command the game handles itself, outside the console system, does not reach listeners (apart from jointeam). For those, hook ISource2GameClients::ClientCommand with KHOOK_VIRTUAL — the sample plugin does exactly that; see Hooking.

Filtering chat

Plain chat messages arrive as say (all chat) and say_team (team chat). A pre listener on them is a chat filter; Supersede drops the message. This is how FUNPLAY's Public plugin stops empty and repeated messages:

chat.cpp
static Action OnPlayerChat(const Source2ModCommandContext& ctx, const Source2ModCommandArgs& args, bool post)
{
    CCSPlayerController* player = CCSPlayerController::FromSlot(ctx.GetPlayerSlot().Get());
    if (!player)
        return Action::Ignore;

    // The message is everything after "say", possibly wrapped in quotes.
    std::string message = args.ArgS();
    if (!message.empty() && message.front() == '"') message.erase(message.begin());
    if (!message.empty() && message.back() == '"')  message.pop_back();

    if (message.empty())
        return Action::Supersede;

    return Action::Ignore;
}

// in Load()
REGISTER_CON_LISTENER("say", OnPlayerChat, false);
REGISTER_CON_LISTENER("say_team", OnPlayerChat, false);

A message that starts with a chat trigger (!, /, …) does not reach the say listeners — the core dispatches it as the command it names instead. With a pre listener on that command name you can see it (and, with Supersede, hide the chat line); a say filter only ever sees ordinary chat.

Listeners vs. commands

You want to…Use
Add a new commandREGISTER_CON_COMMAND
Add a chat word without a console commandREGISTER_CHAT_LISTENER
Watch or block an existing commandREGISTER_CON_LISTENER
Replace an existing commandREGISTER_CON_LISTENER, pre, do your thing, return Action::Supersede

In a script the three are Source2ModCommands.RegisterConCommand, RegisterChatListener and RegisterConListener.

Full reference: ISource2ModCommands.

On this page