Team Fortress 2 Bind Generator
Installation
You can use the pre built executable package available for download. This includes everything you need to get running.
If you don't trust me or you want to edit or view the code yourself, you must have the below requirements met:
The script only requires python to be installed. It uses no external dependencies. I have only tested with python 3.3+, but 2.7 may also work.
Download the zip file and extract it anywhere you want. It does not need to be in the tf2 directory to work.
You can of course clone the repo as well if you have a git client installed.
Usage
For this to function you must have a key bound to reload the newly updated binds generated on the fly. Add a bind like the following to your autoexec.cfg file or the appropriate cfg file for your configuration, replacing the bound key to one of your choosing.
bind f1 "exec log_parser; bind_gen"
If you don't add a key to reload the log_parser.cfg file you will never get updated player names. So this is very important and probably the most likely area to encounter a problem with the script.
Additionally, you must also add the following launch options to TF2:
-condebug -conclearlog
This will enable the required output of tf2 log messages to the default log path. -conclearlog is optional but it will clear the log file upon startup so the log file should not get too large.
usage: tf2_bind_gen.py [-h] [--log_path LOG_PATH] [--config_path CONFIG_PATH]
[--bind_key BIND_KEY] [--test] [--binds BINDS]
[--stats STATS]
TF2 Log Tail Parser
optional arguments:
-h, --help show this help message and exit
--log_path LOG_PATH Path to console.log generated by TF2 (default:
C:\Program Files (x86)\Steam\steamapps\common\Team
Fortress 2\tf\console.log)
--config_path CONFIG_PATH
Path to the .cfg file to be generated (default:
C:\Program Files (x86)\Steam\steamapps\common\Team
Fortress 2\tf\cfg\log_parser.cfg
--bind_key BIND_KEY Keyboard shortcut used for chat bind (default: f2)
--test Test parsing your existing log files (default: False)
--binds BINDS Path to your custom binds file. (default: binds.txt)
--stats STATS Path to your stats file. (default: stats.json)
The default settings should work for most users. If you want to change the key that the bind gets bound too set the --bind_key option to the key of your choosing. For example:
tf2_bind_gen.py --bind_key f4
Once running you can use your two binds to reload the log_parser.cfg and to execute your bind.
Customizing Your Sick Memes
You can customize the binds used by creating or editing the binds.txt file. The format is 1 bind per line and has the following variables which can be used:
- {player} - Your player name
- {victim} - The name of the person you killed
- {weapon} - The weapon you killed them with. (only console names so: "tf_projectile_rocket" and not "Rocket Launcher")
- {kills} - The number of times you've killed a player with that name. Doesnt currently track Steam ID.
Some examples are below:
Get rekt {victim} That makes it {total}! :)
[generic] Why so mad {victim}?
[generic] {player} rekt {victim} LOL!
[market_gardener.crit] Try looking up next time {victim}!
[tf_projectile_rocket.crit] EZ Crit! Thanks {victim}!
[tf_projectile_rocket] Thanks for the farm {victim}!
[world] {player} > world > {victim}
You can specify binds for specific weapon kills and whether they are a crit or not. These keys correspond to the weapon names you see in the console kill log. I don't have a list of all of these, you can check your console logs if you don't know the name of something you want to use. See above for examples of crit and non-crit weapon binds. If no [key] is defined, it will be defined as [generic] for you by default.
If you are using an Australium weapon, there is no way to know if a kill is a crit or not, all kills are considered a crit according to the console log. So if you want to match a australium weapon, make sure to always add ".crit" to the bind key configuration.
If you are using a file other than binds.txt you can change the default path to your binds by specifying the --binds flag value.
tf2_bind_gen.py --binds binds_custom.txt
Will this get me VAC banned?
Short answer: No.
Less short answer: No, all this script does is read and parse the tf2 log file which is a plain text file. It then write a new .cfg. There is no functionality to read live TF2 data from memory, attaching to running process or DLL injections happening at all.
Example

import json
import re
from collections import defaultdict
from os import environ
from os.path import isfile, join, exists
import logging
from random import choice
logger = logging.getLogger("bind_gen")
class KillMsg(object):
def __init__(self, player, victim, weapon, crit, total=0):
self.player = player
self.victim = victim
self.weapon = weapon
self.crit = True if "crit" in crit else False
self.total = total
@property
def key(self):
if self.crit:
return "{}.crit".format(self.weapon)
else:
return self.weapon
def __str__(self):
return "victim: {} weapon: {} crit: {}".format(self.victim, self.weapon, self.crit)
class StatLogger(object):
def __init__(self, stats_file, write_every=5):
self.stats_file = stats_file
self.writer_count = 0
self.write_every = write_every
self.stats = defaultdict(int)
def write(self):
with open(self.stats_file, "w", encoding='utf-8', errors='ignore') as log:
json.dump(self.stats, log)
def read(self):
if not exists(self.stats_file):
return False
with open(self.stats_file, encoding='utf-8', errors='ignore') as log:
x = json.load(log)
self.stats.update(x)
def get(self, user_name):
return self.stats[user_name]
def increment(self, user_name):
self.stats[user_name] += 1
self.writer_count += 1
if self.writer_count >= self.write_every:
self.write()
self.writer_count = 0
return self.stats[user_name]
class LogParser(object):
# NAME killed NAME with GUN.
_re_kill = re.compile(r"^(.+)\skilled\s(.+)\swith\s(.+)(\.|\. \(crit\))$")
# NAME connected
_re_connected = re.compile(r"^(.+)\sconnected$")
# Disconnecting from abandoned match server
_re_disconnect = re.compile(r"(^Disconnecting from abandoned match server$|\(Server shutting down\)$)")
_re_bind_key = re.compile(r"^\[(.+?)\](.+?)$")
def __init__(self, log_path, cfg_path, binds_file, stats_file):
self.log_path = log_path
self.cfg_path = cfg_path
self.username = None
self.default_bind_key = "generic"
self.templates = self.read_binds(binds_file)
self.stats = StatLogger(stats_file)
def parse_log(self, line):
if self.username is None:
m = self._re_connected.search(line)
if m:
self.username = m.groups()[0]
logger.info("Connected with username: {}".format(self.username))
return
elif self._re_disconnect.match(line):
self.username = None
logger.info("Disconnected from server")
self.stats.write()
else:
match = self._re_kill.search(line)
if match:
msg = KillMsg(*match.groups())
if msg.player == self.username:
msg.total = self.stats.increment(msg.victim)
logger.debug(msg)
self.write_cfg(msg)
return msg
def read_binds(self, file_name):
found = 0
binds = defaultdict(list)
for line in open(file_name, encoding='utf-8', errors='ignore').readlines():
real_line = line.strip()
if real_line not in binds:
match_key = self._re_bind_key.search(real_line)
if match_key:
key, raw_msg = match_key.groups()
msg = raw_msg.strip()
else:
key = self.default_bind_key
msg = real_line
binds[key].append(msg)
found += 1
logger.info("Loaded {} binds".format(found))
return binds
def write_cfg(self, msg: KillMsg):
with open(self.cfg_path, 'w+', encoding='utf-8', errors='ignore') as cfg:
cfg.write('echo "Loaded log_parser.cfg"\n')
alias = '''alias bind_gen "say {} "'''.format(self.gen_message(msg))
logger.debug(alias)
cfg.write(alias + "\n")
def gen_message(self, msg: KillMsg):
try:
template = choice(self.templates[msg.key])
except IndexError:
template = choice(self.templates[self.default_bind_key])
output_str = template.format(victim=msg.victim, player=msg.player, weapon=msg.weapon,
total=msg.total)
return output_str
def start(self):
for line in self.tail():
self.parse_log(line)
def stop(self):
logger.info("Shutting down...")
self.stats.write()
def read_file(self, log_file):
for line in open(log_file, encoding='utf-8', errors='ignore').readlines():
msg = self.parse_log(line)
if msg:
logger.info(self.gen_message(msg))
def tail(self):
first_call = True
while True:
try:
with open(self.log_path, encoding='utf-8', errors='ignore') as log_file:
if first_call:
log_file.seek(0, 2)
first_call = False
latest_data = log_file.read()
while True:
if '\n' not in latest_data:
try:
latest_data += log_file.read()
except UnicodeDecodeError as err:
logger.exception(err)
continue
if '\n' not in latest_data:
yield ''
if not isfile(self.log_path):
break
continue
latest_lines = latest_data.split('\n')
if latest_data[-1] != '\n':
latest_data = latest_lines[-1]
else:
latest_data = log_file.read()
for line in latest_lines[:-1]:
yield line + '\n'
except IOError:
yield ''
if __name__ == "__main__":
import argparse
try:
program_files_path = environ['PROGRAMFILES(X86)']
except KeyError:
program_files_path = environ['PROGRAMFILES']
log_path_default = join(program_files_path, r"Steam\steamapps\common\Team Fortress 2\tf\console.log")
config_path_default = join(program_files_path, r"Steam\steamapps\common\Team Fortress 2\tf\cfg\log_parser.cfg")
parser = argparse.ArgumentParser(description='TF2 Log Tail Parser')
parser.add_argument('--log_path', default=log_path_default,
help="Path to console.log generated by TF2 (default: {})".format(log_path_default))
parser.add_argument('--config_path', default=config_path_default,
help="Path to the .cfg file to be generated (default: {}".format(config_path_default))
parser.add_argument('--test', action='store_true', help="Test parsing your existing log files (default: False)")
parser.add_argument('--binds', default="binds.txt", help="Path to your custom binds file. (default: binds.txt)")
parser.add_argument('--stats', default="stats.json", help="Path to your stats file. (default: stats.json)")
parser.add_argument('--debug', action='store_true', help="Set the logging level to debug. (default: False)")
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO,
format="[TF2BindGen] [%(levelname)s] %(message)s")
parser = LogParser(args.log_path, args.config_path, args.binds, args.stats)
if args.test:
parser.read_file(log_path_default)
else:
try:
parser.start()
except KeyboardInterrupt:
parser.stop()