A MetaHookSv plugin for GoldSrc (Sven Co-op, Half-Life) that forwards game events, chat messages, and console output to a configurable UDP address. Also accepts remote console commands via UDP.
ChatForwarder hooks into several GoldSrc engine messaging systems and aggregates their output into a single UDP stream. A remote observer — a chat bot, logger, dashboard, or automation script — simply listens on a UDP port and reacts to tagged messages.
Typical use case: React to in-game chat, player join/leave events (... has joined the game), and server notifications from an external script.
Note: The same text may appear in multiple streams depending on engine behavior (e.g., a chat message can appear in both
SayTextandOutputDebugString). Deduplication, if needed, is the responsibility of the receiving end.
Every UDP packet has the structure:
[1 Byte Tag] [8 Bytes SteamID64 LE] [Message Body (UTF-8)]
| Tag | Hex | Source Hook | Description |
|---|---|---|---|
PING |
0x10 |
Internal (cf_ping) |
Diagnostics ping probe (ping <nonce>). Old servers ignore this tag. |
CHAT |
0x12 |
UserMsg: SayText |
In-game chat. Arguments (%s) substituted inline. Carries the author's SteamID64. |
GAME |
0x13 |
UserMsg: TextMsg |
Game notifications — includes join/leave events, radio, etc. |
NET |
0x14 |
cl_parsefunc: print |
Raw console output received from the server. |
SYS |
0x15 |
IAT: OutputDebugStringA |
Internal engine debug/system log output. |
STUFF |
0x16 |
cl_parsefunc: stufftext |
Server-to-client command strings. |
Unknown tag bytes should be ignored by the client to maintain forward compatibility.
Delivery is plain UDP: there are no sequence numbers or acknowledgements, so packets can be lost, duplicated or arrive out of order.
The binary payload is always:
[0] tag_byte : uint8 - message type
[1..8] steamid64 : uint64 - little-endian SteamID64 (chat author, or 0)
[9..] message : bytes - UTF-8 message body, not null-terminated
Parsing example:
tag = buf[0]
steamid = struct.unpack_from("<Q", buf, 1)[0]
text = buf[9:].decode("utf-8", errors="replace")steamid == 0 means no SteamID is available: Steam is unavailable or not logged in, a LAN game, a bot, the engine did not provide the value, or it is not a valid individual account. For CHAT (0x12) the field is the message author's SteamID64 (read from the engine's player table on Sven Co-op, falling back to the standard hud_player_info_t), with the local player's ID as a fallback for your own messages. All other packet types use 0. Availability depends on the game and engine version; check with cf_debug 1.
Incoming strings are processed by CleanMessage before sending:
- Control characters
0x00–0x1Fare stripped, except:0x01–0x04— GoldSrc color codes, preserved.\n,\r,\t— preserved.
- The tag byte is prepended before the cleaned string. If the game string itself starts with
0x02(player name color), the packet will look like12 02 ...— this is intentional. - The SteamID64 field is always 8 bytes (0 when not applicable).
Any UTF-8 string sent to cf_listen_port is injected into the game console as a command. Commands are executed in the main game thread via HUD_Frame with an optional delay between them (cf_command_delay).
There is no authentication. Any host that can reach cf_listen_port can run console commands. cf_allow_ip compares the packet's source address only, and UDP source addresses can be spoofed. If you only need chat, set cf_say_only 1; if the peer runs on the same PC, set cf_bind_ip 127.0.0.1.
# Example: send a console command to the game
echo "say Hello from Python!" | nc -u 127.0.0.1 26001
- Copy
ChatForwarder.dll(andChatForwarder_AVX2.dll/ChatForwarder_AVX.dllif applicable) to yoursvencoop/metahook/plugins/directory. - Add
ChatForwarder.dlltosvencoop/metahook/configs/plugins.lst.
MetaHookSv automatically selects the most capable variant (AVX2 → AVX → SSE2 → SSE → base) for the current CPU.
All CVars are saved to config (FCVAR_ARCHIVE). Changes are picked up about once per second; cf_reload re-reads them right away and recreates the sockets.
| CVar | Default | Description |
|---|---|---|
cf_enabled |
1 |
Master switch. 0 = plugin idle (no send, no receive). |
cf_bind_ip |
0.0.0.0 |
Local IPv4 address to bind the listener and sender sockets to (0.0.0.0 for all interfaces, or a specific LAN IP). |
cf_server_ip |
127.0.0.1 |
Target IP address for outgoing UDP messages. |
cf_server_port |
26000 |
Target port for outgoing UDP messages. |
cf_listen_port |
26001 |
Local UDP port for incoming console commands. |
cf_allow_ip |
* |
Accept packets only from this source IP (* = any). Compared against the packet's source address, which can be spoofed. A peer on the same machine that sends to a LAN IP arrives from that LAN IP, not 127.0.0.1. |
cf_say_only |
0 |
If 1: accept only say and say_team commands. Commands containing ;, CR, LF or NUL are rejected. |
cf_listen_only |
0 |
If 1: only receive commands, do not send any messages. |
cf_command_delay |
0 |
Minimum seconds between consecutive console command executions. |
cf_debug |
0 |
If 1: print forwarded messages, network debug info, and client SteamIDs to the in-game console. |
Diagnostic and management commands available in the game console:
| Command | Description |
|---|---|
cf_status |
Prints detailed status: listener and sender socket states, OS routing probe to cf_server_ip, packet counters (RX, TX, filtered, denied), pong count, RTT, server-seen address, and configuration warnings. |
cf_ifaces |
Lists all local IPv4 network interfaces via SIO_GET_INTERFACE_LIST, marking the one used for the route to cf_server_ip. |
cf_ping |
Queues a diagnostic ping probe (0x10) to cf_server_ip:cf_server_port. A pong-capable peer answers with 0x1F PONG <nonce> <ip>:<port>, updating RTT and peer-seen address. |
cf_reload |
Re-reads all CVars, recreates the listener and sender sockets, and resets statistics counters. |
The project targets Visual Studio 2022+ (v143/v145 toolset), 32-bit Release.
A standalone build script is included — no need for the full MetaHookSv solution.
# Build Release and Release_AVX2
.\build_plugin.ps1
# Build and package into a ZIP archive in /Builds
.\build_plugin.ps1 -Package
# Build and copy directly to game directory
.\build_plugin.ps1 -CopyTo "C:\Games\SvenCoop\svencoop\metahook\plugins"udp_test_client.py — Reference Python 3 UDP test client and connection debugger.
- Interface Selection: Lists available network interfaces on startup or accepts
-b / --bind <IP>. - Target Routing: Sends interactive console commands to the plugin target address (
-s / --server, default127.0.0.1,-sp / --sport, default26001). - Ping / Pong: Responds to diagnostic ping probes (
0x10) with0x1F PONG <nonce> <src_ip>:<src_port>, allowingcf_statusto measure RTT. - Display & Formatting: Renders GoldSrc color codes (
0x01–0x04) as ANSI terminal colors and suppresses duplicate packets within a 200 ms window (--show-dupsto disable).
# Local testing (default 127.0.0.1)
python udp_test_client.py
# LAN testing
python udp_test_client.py -b 192.168.0.15 -s 192.168.0.15 -sp 26001 -lp 26000- Worker tasks run via the MetaHookSv global thread pool (
GetGlobalThreadPool). - Outgoing packets are queued and sent asynchronously.
- Inbound UDP commands are queued and executed on the main engine thread in
HUD_Frame. OutputDebugStringAIAT hook captures engine debug log output with line buffering.HUD_GetStudioModelInterfaceis wrapped only to read the engine's player table; the original is always called.- Engine hooks are registered once on initial load.