Team Fortress 2 Bind Generator

Authors:

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

Example 1

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()