├── .gitignore ├── .gitmodules ├── README.md ├── bootstrap.sh ├── data ├── known_services.txt └── util.org ├── hearthy ├── __init__.py ├── datasource │ ├── __init__.py │ ├── cdump.py │ └── hcapng.py ├── db │ ├── __init__.py │ └── cards.py ├── examples │ ├── proxy_squirrel.py │ └── verbose_tracker.py ├── exceptions.py ├── protocol │ ├── __init__.py │ ├── decoder.py │ └── utils.py ├── proxy │ ├── __init__.py │ ├── intercept.py │ ├── pipe.py │ └── proxy.py ├── tracker │ ├── entity.py │ ├── processor.py │ └── world.py └── ui │ ├── __init__.py │ ├── common.py │ ├── tk │ ├── __init__.py │ ├── entitybrowser.py │ ├── streamlist.py │ └── streamview.py │ └── tkmain.py ├── helper └── hcapture.c ├── requirements.txt └── screenshots ├── entitybrowser.png └── streamview.png /.gitignore: -------------------------------------------------------------------------------- 1 | # Cached byte-compiled Python modules. 2 | **/__pycache__/ 3 | # Python modules generated by protoc. 4 | bnet/ 5 | pegasus/ 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "hs-data"] 2 | path = hs-data 3 | url = https://github.com/HearthSim/hs-data.git 4 | [submodule "hs-proto"] 5 | path = hs-proto 6 | url = https://github.com/HearthSim/hs-proto.git 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hearthy # 2 | Hearthy is a decoder for the network protocol used by [Hearthstone](http://us.battle.net/hearthstone/en/). 3 | This project is still in early stages of development. Only the game protocol has been implemented so far. 4 | 5 | ## Requirements ## 6 | 7 | System requirements: 8 | 9 | - [Python](https://www.python.org/), version 3.4 or higher 10 | - [Protocol Buffers](https://developers.google.com/protocol-buffers/) compiler (protoc) 11 | - [Git](https://git-scm.com/) 12 | 13 | Python requirements: 14 | 15 | - [Hearthstone Python library](https://github.com/HearthSim/python-hearthstone) 16 | - [Protocol Buffers library](https://developers.google.com/protocol-buffers/) 17 | 18 | The easiest way to install the Python requirements is to use virtualenvwrapper: 19 | 20 | ```sh 21 | mkvirtualenv -r requirements.txt -p `which python3` hearthy 22 | ``` 23 | 24 | To return to this virtualenv later, run: 25 | 26 | ```sh 27 | workon hearthy 28 | ``` 29 | 30 | The [card database](https://github.com/HearthSim/hs-data) and the [protocol definitions](https://github.com/HearthSim/hs-proto) are kept in separate repositories, which are included into Hearthy via a Git submodules. To fetch or update this external data, run the bootstrap script: 31 | 32 | ```sh 33 | ./bootstrap.sh 34 | ``` 35 | 36 | You can re-run the bootstrap script to update the submodules and regenerate the protocol buffer code. 37 | 38 | ## UI ## 39 | Some basic UI tools for exploring protocol dumps are provided. 40 | ![tk ui](screenshots/streamview.png?raw=true) 41 | ![tk ui](screenshots/entitybrowser.png?raw=true) 42 | 43 | The ui tools work on capture files generated by `helper/hcapture.c`. You can invoke the ui using the `hearthy.ui.tkmain` module: 44 | ```sh 45 | python3 -m hearthy.ui.tkmain 46 | ``` 47 | 48 | This also works on live captures if you use a pipe, i.e.: 49 | ```sh 50 | tail -c+0 -f | python3 -m hearthy.ui.tkmain /dev/stdin 51 | ``` 52 | 53 | ## Supported Packets ## 54 | The following packets are currently supported: 55 | Note: S->C means the server sends it to the client 56 | 57 | Packet Name | Dir | Description 58 | --------------- | --- | ---------- 59 | PowerHistory | S>C | State changes 60 | UserUI | Bi | UI actions 61 | TurnTimer | S>C | Seconds left in turn 62 | GetGameState | C>S | Game state query 63 | StartGameState | S>C | Game/Player info 64 | FinishGameState | S>C | Sent after StatGameState 65 | GameSetup | S>C | Game Board and Rules 66 | GameCancelled | S>C | Game has been cancelled 67 | EntityChoice | S>C | Used during mulligan 68 | ChooseEntities | C>S | Response to EntityChoice 69 | AllOptions | S>S | List of possible client actions 70 | ChooseOption | C>S | Response to AllOptions 71 | BeginPlaying | S>C | Playing mode 72 | AuroraHandshake | C>S | Identification 73 | GameStarting | S>C | Sent after login 74 | PreLoad | ??? | ??? 75 | PreCast | ??? | ??? 76 | Notification | ??? | ??? 77 | NAckOption | ??? | ??? 78 | GiveUp | ??? | Concede? 79 | DebugMessage | ??? | ??? 80 | ClientPacket | ??? | ??? 81 | 82 | For detailed contents of the packets see `protocol.mtypes`. 83 | 84 | ## Example Usage ## 85 | The usual usage of this package is to use the `protocol.utils.Splitter` class to split a network stream into packets followed by `protocol.decoder.decode_packet` to decode the packets. 86 | 87 | ```python 88 | from hearthy.protocol.utils import Splitter 89 | from hearthy.protocol.decoder import decode_packet 90 | from hearthy.protocol import mtypes 91 | 92 | s = Splitter() 93 | 94 | while True: 95 | buf = network_stream.read(BUFSIZE) 96 | for message_type, buf in s.feed(buf): 97 | decoded = decode_packet(message_type, buf) 98 | # do something with the decoded packet 99 | # test which packet it is using 100 | # e.g. isinstance(decoded, mtypes.AuroraHandShake) 101 | ``` 102 | 103 | ## Network Capture ## 104 | A tool to automatically record tcp streams has been included in `helper/hcapture.c`. It uses `libnids` which uses `libpcap` to capture network traffic and performs tcp defragmentation and reassembly. The tool looks for tcp connections on port `1119` and saves them to a file. Only linux is currently supported - patches are welcome. 105 | 106 | To compile: 107 | ```sh 108 | gcc hcapture.c -Wall -lnids -o capture 109 | ``` 110 | 111 | Usage: 112 | ```sh 113 | ./capture -i mynetworkiface outfile.hcapng 114 | ``` 115 | Python code to decode the capture file is provided in `datasource/hcapng.py`. 116 | 117 | ### Example ### 118 | Reads a hcapture capture file and splits the network streams into packets. 119 | 120 | ```python 121 | import sys 122 | from datetime import datetime 123 | 124 | from hearthy.datasource import hcapng 125 | from hearthy import exceptions 126 | 127 | class Connection: 128 | def __init__(self): 129 | self._s = [Splitter(), Splitter()] 130 | 131 | def feed(self, who, buf): 132 | for atype, buf in self._s[who].feed(buf): 133 | # decode and handle packet 134 | 135 | d = {} 136 | with open(filename, 'rb') as f: 137 | gen = hcapng.parse(f) 138 | 139 | header = next(gen) 140 | print('Recording started at {0}'.format( 141 | datetime.fromtimestamp(header.ts).strftime('%Y.%m.%d %H:%M:%S'))) 142 | 143 | for ts, event in gen: 144 | if isinstance(event, hcapng.EvClose): 145 | if event.stream_id in d: 146 | del d[event.stream_id] 147 | elif isinstance(event, hcapng.EvData): 148 | if event.stream_id in d: 149 | try: 150 | d[event.stream_id].feed(event.who, event.data) 151 | except exceptions.BufferFullException: 152 | # Usually means that the tcp stream wasn't a game session. 153 | del d[event.stream_id] 154 | elif isinstance(event, hcapng.EvNewConnection): 155 | d[event.stream_id] = Connection() 156 | ``` 157 | -------------------------------------------------------------------------------- /bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | echo "Updating Git submodules..." 5 | git submodule init 6 | git submodule update 7 | 8 | echo "Compiling protocol buffers..." 9 | protoc -I=hs-proto --python_out=. hs-proto/*/*.proto 10 | 11 | echo "Done." 12 | -------------------------------------------------------------------------------- /data/known_services.txt: -------------------------------------------------------------------------------- 1 | bnet.protocol.notification.NotificationListener 2 | bnet.protocol.friends.FriendsService 3 | bnet.protocol.channel_invitation.ChannelInvitationService 4 | bnet.protocol.resources.Resources 5 | bnet.protocol.account.AccountService 6 | bnet.protocol.presence.PresenceService 7 | bnet.protocol.authentication.AuthenticationServer 8 | bnet.protocol.connection.ConnectionService 9 | bnet.protocol.authentication.AuthenticationClient 10 | bnet.protocol.game_utilities.GameUtilities 11 | bnet.protocol.resources.Resources 12 | bnet.protocol.notification.NotificationServer 13 | bnet.protocol.game_master.GameMasterSubscriber 14 | bnet.protocol.game_master.GameMaster 15 | bnet.protocol.game_master.GameFactorySubscriber 16 | bnet.protocol.friends.FriendsNotify 17 | -------------------------------------------------------------------------------- /data/util.org: -------------------------------------------------------------------------------- 1 | | Fun | Type | Sys | Body | 2 | |--------------------------------------------+-------+-----+-------------------------| 3 | | ConnectAPI.ValidateAchieve | 0x1cc | 0 | ValidateAchieve | 4 | | ConnectAPI.TriggerLaunchEvent | 0x12a | 0 | TriggerLaunchDayEvent | 5 | | ConnectAPI.TrackClient | 0x0e4 | 0 | OneClientTracking | 6 | | ConnectAPI.SubmitThirdPartyPurchaseReceipt | 0x125 | 1 | SubmitThirdPartyReceipt | 7 | | ConnectAPI.SetProgress | 0x0e6 | 0 | SetProgress | 8 | | ConnectAPI.SetDefaultCardBack | 0x123 | 0 | SetCardBack | 9 | | ConnectAPI.SetDeckCardBack | 0x123 | 1 | SetCardBack | 10 | | ConnectAPI.SetClientOptionULong | 0x0ef | 0 | SetOptions | 11 | | ConnectAPI.SetClientOptionLong | 0x0ef | 0 | SetOptions | 12 | | ConnectAPI.SetAdventureOptions | 0x136 | 0 | SetAdventureOptions | 13 | | ConnectAPI.SendDeckData | 0x0de | 0 | DeckCardData | 14 | | ConnectAPI.SendAckCards | 0x0df | 0 | UtilRequestPhase | 15 | | ConnectAPI.RequestAssetsVersion | 0x12f | 0 | GetAssetsVersion | 16 | -------------------------------------------------------------------------------- /hearthy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HearthSim/Hearthy/ebce3779ee51a0d94f9def5df11f3523bd7fc4c5/hearthy/__init__.py -------------------------------------------------------------------------------- /hearthy/datasource/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HearthSim/Hearthy/ebce3779ee51a0d94f9def5df11f3523bd7fc4c5/hearthy/datasource/__init__.py -------------------------------------------------------------------------------- /hearthy/datasource/cdump.py: -------------------------------------------------------------------------------- 1 | import re 2 | from ..protocol.decoder import decode_packet 3 | from ..protocol.utils import hexdump, Splitter 4 | 5 | READ_BUFSIZE = 16 * 1024 6 | 7 | def tokenizer(f): 8 | leftover = '' 9 | in_comment = False 10 | while True: 11 | buf = f.read(READ_BUFSIZE) 12 | if not buf: 13 | break 14 | 15 | fields = re.split('[\n\r, ]', leftover + buf) 16 | for field in filter(None, fields[:-1]): 17 | if field == '/*': 18 | in_comment = True 19 | elif field == '*/': 20 | in_comment = False 21 | else: 22 | if not in_comment: 23 | yield field 24 | leftover = fields[-1] 25 | 26 | if leftover: 27 | yield leftover 28 | 29 | def parse_cdump(f): 30 | t = tokenizer(f) 31 | 32 | header = next(t, None) 33 | ret = [] 34 | 35 | while header != None: 36 | assert(header == 'char') 37 | 38 | # peerN_N[] where N is some decimal? 39 | peer = next(t, None) 40 | assert(peer.startswith('peer')) 41 | assert(peer.endswith('[]')) 42 | p,n = map(int, peer[4:-2].split('_')) 43 | 44 | assert(next(t) == '=') 45 | assert(next(t) == '{') 46 | 47 | l = [] 48 | while True: 49 | a = next(t) 50 | if a == '};': 51 | break 52 | l.append(int(a.rstrip(','), 16)) 53 | 54 | yield (p, n, bytes(l)) 55 | header = next(t, None) 56 | 57 | if __name__ == '__main__': 58 | import sys 59 | if len(sys.argv) < 2: 60 | print('Usage: {0} '.format(sys.argv[0]), file=sys.stderr) 61 | sys.exit(1) 62 | 63 | with open(sys.argv[1], 'r') as f: 64 | s = Splitter() 65 | for p, n, buf in parse_cdump(f): 66 | print('== Client -> Server ==' if p == 0 else '== Server -> Client==') 67 | print('Sequence: {0}'.format(n)) 68 | hexdump(buf) 69 | for atype, buf in s.feed(buf): 70 | decoded = decode_packet(atype, buf) 71 | print('\nFound packet {0}:{1}'.format(atype, type(decoded).__name__)) 72 | print('\nDecoded packet:') 73 | print(decoded) 74 | print() 75 | -------------------------------------------------------------------------------- /hearthy/datasource/hcapng.py: -------------------------------------------------------------------------------- 1 | import struct 2 | 3 | def _format_ipv4(ip): 4 | """ 5 | Converts an ip in numeric form to its dotted form. 6 | Assumes ip is given in native byte order. 7 | """ 8 | return '{3}.{2}.{1}.{0}'.format( ip & 0xff, 9 | (ip >> 8) & 0xff, 10 | (ip >> 16) & 0xff, 11 | (ip >> 24) & 0xff) 12 | 13 | EV_NEW_CONNECTION, EV_CLOSE, EV_DATA = range(3) 14 | 15 | class HCapException(Exception): 16 | """ Base class for all exceptions thrown by hcapng.py """ 17 | pass 18 | 19 | class EvNewConnection: 20 | __slots__ = ['stream_id', 'source', 'dest'] 21 | 22 | @classmethod 23 | def decode(cls, buf): 24 | a = cls() 25 | a.stream_id, saddr, source, daddr, dest = struct.unpack('>'.format( 46 | self.stream_id, self.who, len(self.data)) 47 | 48 | class EvClose: 49 | __slots__ = ['stream_id'] 50 | 51 | @classmethod 52 | def decode(cls, buf): 53 | a = cls() 54 | a.stream_id = struct.unpack(''.format( 59 | self.stream_id) 60 | 61 | class EvHeader: 62 | def __init__(self, ts): 63 | self.ts = ts 64 | 65 | def __repr__(self): 66 | return ''.format(self.ts) 67 | 68 | EXPECTED_VERSION = b'HCaptureV0\x00' 69 | def read_header(stream): 70 | version = stream.read(len(EXPECTED_VERSION)) 71 | if version != EXPECTED_VERSION: 72 | raise HCapException('Expected to read {0!r} but got {1!r}'.format( 73 | EXPECTED_VERSION, version)) 74 | buf = stream.read(8) 75 | timestamp = struct.unpack(' MAX_EVLEN: 95 | raise HCapException('Event length {0} exceeds maximum of {1}'.format( 96 | evlen, MAX_EVLEN)) 97 | 98 | # read event data 99 | buf = stream.read(evlen - PREFIX_LEN) 100 | 101 | if evtype == EV_NEW_CONNECTION: 102 | yield (evtime, EvNewConnection.decode(buf)) 103 | elif evtype == EV_DATA: 104 | yield (evtime, EvData.decode(buf)) 105 | elif evtype == EV_CLOSE: 106 | yield (evtime, EvClose.decode(buf)) 107 | else: 108 | raise HCapException('Got unknown event type 0x{0:02x}'.format(evtype)) 109 | 110 | HEADER_SIZE = len(EXPECTED_VERSION) + 8 111 | MAX_BUF = 64 * 1024 112 | class AsyncParser: 113 | """ 114 | Parser than can be used for asynchronous parsing 115 | (i.e. using asyncore or similar) 116 | """ 117 | def __init__(self, max_buf=MAX_BUF): 118 | self._buf = bytearray(max_buf) 119 | self._buf_start = 0 120 | self._buf_end = 0 121 | self._max_buf = max_buf 122 | 123 | self._needed = HEADER_SIZE 124 | self._parser = self._read_header 125 | 126 | def _read(self, n): 127 | end = self._buf_start + n 128 | buf = self._buf[self._buf_start:end] 129 | self._buf_start = end 130 | return buf 131 | 132 | def _read_event(self): 133 | buf = self._read(self._evlen - PREFIX_LEN) 134 | evtype = self._evtype 135 | evtime = self._evtime 136 | 137 | # read header 138 | self._needed = PREFIX_LEN 139 | self._parser = self._read_prefix 140 | 141 | if evtype == EV_NEW_CONNECTION: 142 | return (evtime, EvNewConnection.decode(buf)) 143 | elif evtype == EV_DATA: 144 | return (evtime, EvData.decode(buf)) 145 | elif evtype == EV_CLOSE: 146 | return (evtime, EvClose.decode(buf)) 147 | else: 148 | raise HCapException('Got unknown event type 0x{0:02x}'.format(evtype)) 149 | 150 | def _read_prefix(self): 151 | self._evlen, self._evtime, self._evtype = struct.unpack(' self._max_buf: 173 | raise HCapException('Buffer size exceeded') 174 | 175 | # copy new data into buf 176 | self._buf[self._buf_end:end] = buf 177 | self._buf_end = end 178 | 179 | # process data 180 | while end - self._buf_start >= self._needed: 181 | data = self._parser() 182 | if data is not None: 183 | yield data 184 | 185 | # copy to make more room 186 | if end > self._max_buf // 4: 187 | inbuff = end - self._buf_start 188 | self._buf[:inbuff] = self._buf[self._buf_start:end] 189 | self._buf_start = 0 190 | self._buf_end = inbuff 191 | 192 | if __name__ == '__main__': 193 | import sys 194 | from datetime import datetime 195 | if len(sys.argv) < 2: 196 | print('Usage: {0} '.format(sys.argv[0])) 197 | sys.exit(1) 198 | 199 | with open(sys.argv[1], 'rb') as f: 200 | gen = parse(f) 201 | 202 | _, header = next(gen) 203 | print('[{0:8}] Recording started at {1}'.format(0, 204 | datetime.fromtimestamp(header.ts).strftime('%Y.%m.%d %H:%M:%S'))) 205 | 206 | for ts, event in gen: 207 | print('[{0:8}] {1!r}'.format(ts, event)) 208 | -------------------------------------------------------------------------------- /hearthy/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HearthSim/Hearthy/ebce3779ee51a0d94f9def5df11f3523bd7fc4c5/hearthy/db/__init__.py -------------------------------------------------------------------------------- /hearthy/db/cards.py: -------------------------------------------------------------------------------- 1 | from hearthstone import cardxml 2 | 3 | from hearthy.exceptions import CardNotFound 4 | 5 | 6 | def _build_card_map(): 7 | cards, xml_ = cardxml.load('hs-data/CardDefs.xml') 8 | return {card_id: card.name for card_id, card in cards.items()} 9 | 10 | _id_to_card = _build_card_map() 11 | 12 | def get_by_id(cardid): 13 | try: 14 | return _id_to_card[cardid] 15 | except KeyError: 16 | raise CardNotFound('Could not find card with id {0}'.format(cardid)) 17 | -------------------------------------------------------------------------------- /hearthy/examples/proxy_squirrel.py: -------------------------------------------------------------------------------- 1 | import asyncore 2 | 3 | from hearthstone.enums import GameTag 4 | 5 | from hearthy.proxy import intercept 6 | from pegasus.game_pb2 import PowerHistory, Tag 7 | 8 | class SquirrelHandler(intercept.InterceptHandler): 9 | def __init__(self, use_premium=False): 10 | super().__init__() 11 | self._use_premium = use_premium 12 | 13 | def on_packet(self, epid, packet): 14 | if isinstance(packet, PowerHistory): 15 | for entry in packet.list: 16 | if entry.HasField('show_entity'): 17 | entry.show_entity.name = 'EX1_tk28' # squirrel 18 | 19 | if self._use_premium: 20 | for tag in entry.show_entity.tags: 21 | if tag.name == GameTag.PREMIUM: 22 | tag.value = 1 23 | break 24 | else: 25 | entry.show_entity.tags.add(name=GameTag.PREMIUM, value=1) 26 | 27 | return intercept.INTERCEPT_ACCEPT 28 | 29 | if __name__ == '__main__': 30 | import argparse 31 | from hearthy.proxy.proxy import Proxy 32 | 33 | parser = argparse.ArgumentParser(description='Transform all cards into squirrels') 34 | parser.add_argument('--premium', action='store_const', default=False, const=True, 35 | help='Make the squirrels premium') 36 | parser.add_argument('--port', type=int, default=5412) 37 | parser.add_argument('--host', default='0.0.0.0') 38 | 39 | args = parser.parse_args() 40 | 41 | proxy_handler = intercept.InterceptProxyHandler(SquirrelHandler, 42 | use_premium=args.premium) 43 | proxy = Proxy((args.host, args.port), handler=proxy_handler) 44 | 45 | asyncore.loop() 46 | -------------------------------------------------------------------------------- /hearthy/examples/verbose_tracker.py: -------------------------------------------------------------------------------- 1 | from hearthy import exceptions 2 | from hearthy.tracker import processor 3 | from hearthy.protocol.decoder import decode_packet 4 | from hearthy.protocol.utils import Splitter 5 | 6 | class Connection: 7 | def __init__(self, source, dest): 8 | self.p = [source, dest] 9 | self._s = [Splitter(), Splitter()] 10 | self._t = processor.Processor() 11 | 12 | def feed(self, who, buf): 13 | for atype, abuf in self._s[who].feed(buf): 14 | decoded = decode_packet(atype, abuf) 15 | self._t.process(who, decoded) 16 | 17 | def __repr__(self): 18 | print(''.format(sys.argv[0])) 27 | sys.exit(1) 28 | 29 | import logging 30 | logging.getLogger().setLevel(logging.DEBUG) 31 | 32 | d = {} 33 | with open(sys.argv[1], 'rb') as f: 34 | parser = hcapng.parse(f) 35 | begin = next(parser) 36 | for ts, event in parser: 37 | if isinstance(event, hcapng.EvClose): 38 | if event.stream_id in d: 39 | del d[event.stream_id] 40 | elif isinstance(event, hcapng.EvData): 41 | if event.stream_id in d: 42 | try: 43 | d[event.stream_id].feed(event.who, event.data) 44 | except exceptions.BufferFullException: 45 | del d[event.stream_id] 46 | elif isinstance(event, hcapng.EvNewConnection): 47 | d[event.stream_id] = Connection(event.source, event.dest) 48 | -------------------------------------------------------------------------------- /hearthy/exceptions.py: -------------------------------------------------------------------------------- 1 | class EncodeError(Exception): 2 | pass 3 | 4 | class DecodeError(Exception): 5 | pass 6 | 7 | class CardNotFound(Exception): 8 | pass 9 | 10 | class EntityNotFound(Exception): 11 | def __init__(self, eid): 12 | super().__init__('Could not find entity with id={0}'.format(eid)) 13 | 14 | class UnexpectedEof(Exception): 15 | def __init__(self): 16 | super().__init__('Encountered an unepxected end of file') 17 | 18 | class BufferFullException(Exception): 19 | pass 20 | -------------------------------------------------------------------------------- /hearthy/protocol/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HearthSim/Hearthy/ebce3779ee51a0d94f9def5df11f3523bd7fc4c5/hearthy/protocol/__init__.py -------------------------------------------------------------------------------- /hearthy/protocol/decoder.py: -------------------------------------------------------------------------------- 1 | """ 2 | Hearthstone Protocol Decoder. 3 | """ 4 | 5 | import struct 6 | from hearthy.exceptions import DecodeError, EncodeError 7 | from pegasus import game_pb2, bobnet_pb2 8 | 9 | def _iter_messages(): 10 | for module in (bobnet_pb2, game_pb2): 11 | for name, message_desc in module.DESCRIPTOR.message_types_by_name.items(): 12 | try: 13 | message_id = message_desc.enum_values_by_name['ID'].number 14 | except KeyError: 15 | pass 16 | else: 17 | yield message_id, getattr(module, name) 18 | messages_by_id = dict(_iter_messages()) 19 | 20 | def encode_packet(packet, buf, offset=0): 21 | try: 22 | packet_type = packet.ID 23 | except AttributeError: 24 | raise EncodeError('No packet type for class {0}'.format(packet.__class__)) 25 | 26 | encoded = packet.SerializeToString() 27 | 28 | end = offset + 8 + len(encoded) 29 | buf[offset:offset+8] = struct.pack(''.format(sys.argv[0]), file=sys.stderr) 48 | sys.exit(1) 49 | 50 | with open(sys.argv[1], 'rb') as f: 51 | s = Splitter() 52 | while True: 53 | buf = f.read(8*1024) 54 | if len(buf) == 0: 55 | break 56 | for atype, buf in s.feed(buf): 57 | print(decode_packet(atype, buf)) 58 | -------------------------------------------------------------------------------- /hearthy/protocol/utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import struct 3 | 4 | from hearthstone.enums import * 5 | 6 | from hearthy import exceptions 7 | 8 | # custom tags that aren't in defined in GameTag 9 | TAG_CUSTOM_NAME = -1 10 | TAG_POWER_NAME = -2 11 | 12 | custom_tags_by_id = { 13 | value: name[4:] 14 | for name, value in locals().items() 15 | if name.startswith('TAG_') and isinstance(value, int) 16 | } 17 | 18 | # 16K ought to be enough for anybody :) 19 | MAX_BUF = 16 * 1024 20 | 21 | def hexdump(src, length=16, sep='.', file=sys.stdout): 22 | FILTER = ''.join([(len(repr(chr(x))) == 3) and chr(x) or sep for x in range(256)]) 23 | lines = [] 24 | for c in range(0, len(src), length): 25 | buf = src[c:c+length] 26 | shex = ' '.join('{0:02x}'.format(x) for x in buf) 27 | printable = ''.join("%s" % ((x <= 127 and FILTER[x]) or sep) for x in buf) 28 | lines.append('{0:08x}: {2:{1}} |{3}|'.format(c, length*3-1, shex, printable)) 29 | print('\n'.join(lines), file=file) 30 | 31 | def format_tag_name(tag_id): 32 | if tag_id < 0: 33 | return custom_tags_by_id[tag_id] 34 | else: 35 | try: 36 | return GameTag(tag_id).name 37 | except ValueError: 38 | # Tag which does not exist in our protocol definitions. 39 | return 'TAG_{0:d}'.format(tag_id) 40 | 41 | _gametag_to_enum = { 42 | GameTag.ZONE: Zone, 43 | GameTag.CARDTYPE: CardType, 44 | GameTag.STEP: Step, 45 | GameTag.NEXT_STEP: Step, 46 | GameTag.RARITY: Rarity, 47 | GameTag.PLAYSTATE: PlayState, 48 | GameTag.MULLIGAN_STATE: Mulligan, 49 | GameTag.STATE: State, 50 | GameTag.FACTION: Faction, 51 | GameTag.CARDRACE: Race, 52 | GameTag.CARD_SET: CardSet, 53 | GameTag.GOLD_REWARD_STATE: GoldRewardState 54 | } 55 | def format_tag_value(tag, value): 56 | enum = _gametag_to_enum.get(tag, None) 57 | if enum: 58 | return '{0}:{1}'.format(value, enum(value)) 59 | else: 60 | return str(value) 61 | 62 | class Splitter: 63 | def __init__(self, max_bufsize=MAX_BUF): 64 | self._buf = bytearray(max_bufsize) 65 | self._offset = 0 66 | self._needed = 8 67 | 68 | # Note atype == -1 <-> we are parsing the header 69 | self._atype = -1 70 | 71 | def feed(self, buf): 72 | newoffset = self._offset + len(buf) 73 | if newoffset > MAX_BUF: 74 | raise exceptions.BufferFullException() 75 | self._buf[self._offset:newoffset] = buf 76 | 77 | while newoffset >= self._needed: 78 | if self._atype == -1: 79 | atype, alen = struct.unpack(''.format(self) 93 | -------------------------------------------------------------------------------- /hearthy/proxy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HearthSim/Hearthy/ebce3779ee51a0d94f9def5df11f3523bd7fc4c5/hearthy/proxy/__init__.py -------------------------------------------------------------------------------- /hearthy/proxy/intercept.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from hearthy.proxy.pipe import SimpleBuf, SimplePipe 3 | from hearthy.protocol import decoder 4 | from pegasus.game_pb2 import Handshake 5 | 6 | MODE_INTERCEPT, MODE_PASSIVE, MODE_LURKING = range(3) 7 | INTERCEPT_REJECT, INTERCEPT_ACCEPT = range(2) 8 | 9 | class SplitterBuf(SimpleBuf): 10 | """ 11 | In principle same functionality as hearthy.protocol.utils.Splitter. 12 | This version has the benefit of correctly handling loop breaks. 13 | """ 14 | def pull_segment(self): 15 | segment = self.peek_segment() 16 | if segment is not None: 17 | self._start += 8 + len(segment[1]) 18 | return segment 19 | 20 | def peek_segment(self): 21 | used = self.used 22 | if used < 8: 23 | return 24 | 25 | atype, alen = struct.unpack(' 0: 85 | splitter.append(buf.last(n_bytes)) 86 | buf._end -= n_bytes 87 | 88 | # decode and forward data 89 | while True: 90 | segment = splitter.pull_segment() 91 | if segment is None: 92 | break 93 | 94 | decoded = decoder.decode_packet(*segment) 95 | action = handler.on_packet(epid, decoded) 96 | 97 | if action == INTERCEPT_REJECT: 98 | # nothing to do in this case 99 | pass 100 | elif action == INTERCEPT_ACCEPT: 101 | offset = decoder.encode_packet(decoded, self._encode_buf) 102 | 103 | # forward packet (hopefully everything fits into the buffer) 104 | buf.append(self._encode_buf[:offset]) 105 | 106 | def _on_pull(self, epid, buf, n_bytes): 107 | if n_bytes == 0: 108 | return 109 | if self._mode == MODE_INTERCEPT: 110 | return self._on_pull_intercept(epid, buf, n_bytes) 111 | elif self._mode == MODE_LURKING: 112 | return self._on_pull_lurking(epid, buf, n_bytes) 113 | # otherwise we are in passive mode and do nothing 114 | 115 | class InterceptProxyHandler: 116 | def __init__(self, intercept_handler, *args, **kwargs): 117 | self._handler_factory = intercept_handler 118 | self._args = args 119 | self._kwargs = kwargs 120 | 121 | def connect(self, ep0, ep1): 122 | handler = self._handler_factory(*self._args, **self._kwargs) 123 | InterceptPipe(ep0, ep1, handler=handler) 124 | 125 | class InterceptHandler: 126 | def __init__(self): 127 | self._interceptor = None 128 | 129 | @property 130 | def interceptor(self): 131 | return self._interceptor 132 | 133 | @interceptor.setter 134 | def interceptor(self, value): 135 | self._interceptor = value 136 | 137 | def on_packet(self, epid, packet): 138 | return INTERCEPT_ACCEPT 139 | 140 | def on_start_intercept(self, first): 141 | pass 142 | -------------------------------------------------------------------------------- /hearthy/proxy/pipe.py: -------------------------------------------------------------------------------- 1 | import asyncore 2 | import socket 3 | import struct 4 | import logging 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | LISTEN_BACKLOG = 10 9 | SO_ORIGINAL_DST = 80 10 | DEFAULT_BUF_SIZE = 64 * 1024 11 | 12 | class SimpleBuf: 13 | def __init__(self, buf_size=DEFAULT_BUF_SIZE): 14 | self._buf = bytearray(buf_size) 15 | self._max = buf_size 16 | self._start = 0 17 | self._end = 0 18 | 19 | def append(self, data): 20 | n = len(data) 21 | assert n <= self.free, 'Not enough buffer space' 22 | 23 | if n <= (self._max - self._end): 24 | end = self._end + n 25 | self._buf[self._end:end] = data 26 | self._end = end 27 | else: 28 | used = self._end - self._start 29 | self._buf[:used] = self._buf[self._start:self._end] 30 | self._start = 0 31 | end = used + n 32 | self._buf[used:end] = data 33 | self._start = 0 34 | self._end = end 35 | 36 | def clear(self): 37 | self._start = self._end = 0 38 | 39 | def last(self, n): 40 | """ 41 | Returns the last n data that have been appended 42 | to the buffer. 43 | """ 44 | assert n <= self.used, 'Requested read exceeds avaiable data' 45 | return bytes(self._buf[self._end-n:self._end]) 46 | 47 | def peek(self, n, offset=0): 48 | """ 49 | Returns the same as read(n) but without consuming 50 | the data. 51 | """ 52 | assert n <= self.used, 'Requested read exceeds avaiable data' 53 | return bytes(self._buf[self._start+offset:self._start+offset+n]) 54 | 55 | def read(self, n=None): 56 | if n is None: 57 | n = self.used 58 | end = self._start + n 59 | buf = bytes(self._buf[self._start:end]) 60 | self._start += n 61 | return buf 62 | 63 | def consume(self, n): 64 | self._start += n 65 | 66 | @property 67 | def free(self): 68 | return self._max - self.used 69 | 70 | @property 71 | def used(self): 72 | return self._end - self._start 73 | 74 | def __repr__(self): 75 | return ''.format(self.free, self.used) 76 | 77 | class TcpEndpoint(asyncore.dispatcher): 78 | def __init__(self): 79 | self.cb = None 80 | self._is_writable = False 81 | self._is_readable = False 82 | self.closed = False 83 | 84 | @classmethod 85 | def from_connect(cls, addr): 86 | a = cls() 87 | a.connected = False 88 | asyncore.dispatcher.__init__(a) 89 | a.create_socket(socket.AF_INET, socket.SOCK_STREAM) 90 | a.connect(addr) 91 | return a 92 | 93 | @classmethod 94 | def from_socket(cls, socket): 95 | """ 96 | Construct endpoint from a connected socket. 97 | """ 98 | a = cls() 99 | asyncore.dispatcher.__init__(a, socket) 100 | a.connected = True 101 | return a 102 | 103 | def handle_connect(self): 104 | print('Yay! Successful connection') 105 | self.connected = True 106 | 107 | def want_pull(self, value): 108 | self._is_readable = value 109 | 110 | def want_push(self, value): 111 | self._is_writable = value 112 | 113 | def handle_read(self): 114 | self.cb(self, 'may_pull', None) 115 | 116 | def handle_write(self): 117 | self.cb(self, 'may_push', None) 118 | 119 | def pull(self, buf): 120 | """ 121 | Tries to receive data from the socket and put it into buf. 122 | Returns the number of bytes appended to the buffer. 123 | """ 124 | tmpbuf = self.recv(buf.free) 125 | buf.append(tmpbuf) 126 | return len(tmpbuf) 127 | 128 | def push(self, buf): 129 | """ 130 | Tries to send data from given buffer to the socket. 131 | """ 132 | sent = self.send(buf._buf[buf._start:buf._end]) 133 | buf.consume(sent) 134 | return sent 135 | 136 | def close(self, reason='???'): 137 | self.closed = True 138 | super().close() 139 | print('Closing due to: {0}'.format(reason)) 140 | 141 | if self.cb is not None: 142 | self.cb(self, 'closed', None) 143 | 144 | def handle_close(self): 145 | self.close('handle_close called') 146 | 147 | def writable(self): 148 | return not self.connected or self._is_writable 149 | 150 | def readable(self): 151 | return not self.connected or self._is_readable 152 | 153 | class TcpEndpointProvider(asyncore.dispatcher): 154 | """ 155 | Listens on specified (host, port) pair, calls callback on each connection. 156 | """ 157 | def __init__(self, listen): 158 | super().__init__() 159 | self.create_socket(socket.AF_INET, socket.SOCK_STREAM) 160 | self.set_reuse_addr() 161 | self.bind(listen) 162 | self.listen(LISTEN_BACKLOG) 163 | 164 | self.logger = logger 165 | self.logger.info('Started, listening on %s:%s', listen[0], listen[1]) 166 | 167 | self.cb = None 168 | 169 | def handle_accepted(self, sock, addr): 170 | self.logger.info('Accepeted connection from %r', addr) 171 | try: 172 | buf = sock.getsockopt(socket.SOL_IP, SO_ORIGINAL_DST, 16) 173 | port, packed_ip = struct.unpack("!2xH4s8x", buf) 174 | ip = socket.inet_ntoa(packed_ip) 175 | self.logger.info('Original Destination: %s:%s', ip, port) 176 | except OSError: 177 | ip = 'not available' 178 | port = 999 179 | self.logger.info('Unable to find original destination') 180 | 181 | if self.cb is None: 182 | self.logger.warning('No callback set - closing socket') 183 | sock.close() 184 | else: 185 | conn = TcpEndpoint.from_socket(sock) 186 | self.cb(self, 'accepted', ((ip, port), conn)) 187 | 188 | class SimplePipe: 189 | def __init__(self, a, b): 190 | self._ep = [a, b] 191 | self._bufs = [SimpleBuf(), SimpleBuf()] 192 | 193 | a.want_pull(True) 194 | b.want_pull(True) 195 | 196 | a.cb = self._on_endpoint_event 197 | b.cb = self._on_endpoint_event 198 | 199 | 200 | def _on_pull(self, epid, buf, n_bytes): 201 | """ 202 | Called when n_bytes of data have been pulled from the endpoint 203 | specified by epid. The data is avaiable as the last n_bytes 204 | of buf. 205 | """ 206 | # to be implemented by subclasses 207 | pass 208 | 209 | def _on_push(self, epid): 210 | """ 211 | Called when some data has been pushed to an endpoint. 212 | """ 213 | # to be implemented by subclasses 214 | pass 215 | 216 | def _on_endpoint_event(self, ep, ev_type, ev_data): 217 | # Careful here! This function has to be reentrant safe! 218 | # This is due to asyncore behaviour in wich pushes or pulls 219 | # can result in connection close. 220 | epid = self._ep.index(ep) 221 | opid = 1 - epid 222 | op = self._ep[opid] 223 | 224 | if ev_type == 'may_push': 225 | n = ep.push(self._bufs[epid]) 226 | self._on_push(epid) 227 | ep.want_push(self._bufs[epid].used > 0) 228 | op.want_pull(not ep.closed and self._bufs[epid].free > 0) 229 | elif ev_type == 'may_pull': 230 | n = ep.pull(self._bufs[opid]) 231 | self._on_pull(epid, self._bufs[opid], n) 232 | ep.want_pull(self._bufs[opid].free > 0) 233 | op.want_push(not ep.closed and self._bufs[opid].used > 0) 234 | elif ev_type == 'closed': 235 | # This should be called twice - exactly once for each endpoint. 236 | # 237 | # When we receive a close event we still try to send 238 | # any outstanding data to the other client. 239 | if not op.closed and self._bufs[opid].used == 0: 240 | # no outstanding data, may close other side 241 | print('no outstanding data') 242 | op.close('remote closed') 243 | 244 | if op.closed and not ep.closed and self._bufs[epid].used == 0: 245 | # No outstanding send data, close other connection! 246 | ep.close('remote closed') 247 | 248 | def __repr__(self): 249 | return ''.format(self._ep, self._closed, self._bufs) 250 | 251 | if __name__ == '__main__': 252 | conns = [] 253 | def cb(sender, ev_type, ev_data): 254 | if ev_type != 'accepted': 255 | return 256 | dst, conn = ev_data 257 | conns.append(conn) 258 | if len(conns) == 2: 259 | a = conns.pop() 260 | b = conns.pop() 261 | p = SimplePipe(a, b) 262 | 263 | provider = TcpEndpointProvider(('0.0.0.0', 5432)) 264 | provider.cb = cb 265 | 266 | asyncore.loop() 267 | -------------------------------------------------------------------------------- /hearthy/proxy/proxy.py: -------------------------------------------------------------------------------- 1 | """ 2 | Basic tcp proxy server using asyncore. 3 | Relies on SO_ORIGINAL_DST which only works on linux/ipv4. 4 | """ 5 | from hearthy.proxy import pipe 6 | 7 | class BasicProxyHandler: 8 | @classmethod 9 | def connect(self, ep0, ep1): 10 | pipe.SimplePipe(ep0, ep1) 11 | 12 | class Proxy: 13 | def __init__(self, listen, handler): 14 | provider = pipe.TcpEndpointProvider(listen) 15 | provider.cb = self._on_connection 16 | self._handler = handler 17 | 18 | def _on_connection(self, provider, ev_type, ev_data): 19 | """ Called when a connection to the proxy has been established. """ 20 | addr_orig, ep = ev_data 21 | 22 | remote = pipe.TcpEndpoint.from_connect(addr_orig) 23 | self._handler.connect(ep, remote) 24 | 25 | if __name__ == '__main__': 26 | import asyncore 27 | p = Proxy(('0.0.0.0', 5412), handler=BasicProxyHandler) 28 | asyncore.loop() 29 | -------------------------------------------------------------------------------- /hearthy/tracker/entity.py: -------------------------------------------------------------------------------- 1 | from hearthstone import enums 2 | from hearthstone.enums import GameTag 3 | 4 | from hearthy.protocol.utils import ( 5 | TAG_CUSTOM_NAME, TAG_POWER_NAME, format_tag_name, format_tag_value 6 | ) 7 | from hearthy.exceptions import CardNotFound 8 | from hearthy.db import cards 9 | 10 | class EntityBase: 11 | def __init__(self, eid, tag_list): 12 | self._eid = eid 13 | self._tags = dict(tag_list) 14 | 15 | @property 16 | def id(self): 17 | return self._eid 18 | 19 | def __getitem__(self, tag): 20 | return self._tags.get(tag, None) 21 | 22 | def __contains__(self, tag): 23 | return tag in self._tags 24 | 25 | def __str__(self): 26 | # TODO: find a nice representation 27 | custom = self[TAG_CUSTOM_NAME] 28 | if custom: 29 | return '[{0!r}]'.format(custom) 30 | 31 | power = self[TAG_POWER_NAME] 32 | if power: 33 | try: 34 | cardname = cards.get_by_id(power) 35 | except CardNotFound: 36 | cardname = power 37 | else: 38 | cardname = '?' 39 | 40 | zone = self[GameTag.ZONE] 41 | if zone: 42 | where = enums.Zone(zone).name.capitalize() 43 | else: 44 | where = '?' 45 | 46 | whom = 'Player{0}'.format(self[GameTag.CONTROLLER]) 47 | 48 | return '[{0}: {1!r} of {2} in {3}]'.format(self.id, cardname, whom, where) 49 | 50 | class Entity(EntityBase): 51 | pass 52 | 53 | class MutableEntity(EntityBase): 54 | def __setitem__(self, tag, value): 55 | self._tags[tag] = value 56 | 57 | def freeze(self): 58 | self.__class__ = Entity 59 | return self 60 | 61 | class MutableView(EntityBase): 62 | def __init__(self, entity): 63 | self._e = entity 64 | self._eid = entity.id 65 | self._tags = dict() 66 | 67 | @property 68 | def id(self): 69 | return self._eid 70 | 71 | def __getitem__(self, tag): 72 | value = self._tags.get(tag, None) 73 | if value is None: 74 | value = self._e[tag] 75 | return value 76 | 77 | def __setitem__(self, tag, value): 78 | oldvalue = self[tag] 79 | if oldvalue != value: 80 | self._tags[tag] = value 81 | 82 | def __contains__(self, tag): 83 | return tag in self._tags or tag in self._e 84 | 85 | def __str__(self): 86 | ret = super().__str__() 87 | for key, val in self._tags.items(): 88 | oldval = self._e[key] 89 | ret += ('\n\ttag {1}:{2} {3} -> {4}'.format( 90 | self, 91 | key, 92 | format_tag_name(key), 93 | format_tag_value(key, oldval) if oldval else '(unset)', 94 | format_tag_value(key, val))) 95 | return ret 96 | 97 | -------------------------------------------------------------------------------- /hearthy/tracker/processor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from hearthy.tracker.world import World 3 | from hearthy.protocol.utils import ( 4 | TAG_CUSTOM_NAME, TAG_POWER_NAME, format_tag_name, format_tag_value 5 | ) 6 | from hearthy.tracker.entity import Entity 7 | from pegasus.game_pb2 import PowerHistory 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | class Processor: 12 | def __init__(self): 13 | self._world = World() 14 | self.logger = logger 15 | 16 | def process(self, who, what): 17 | with self._world.transaction() as t: 18 | self._process(who, what, t) 19 | 20 | def _process(self, who, what, t): 21 | if isinstance(what, PowerHistory): 22 | for power in what.list: 23 | self._process_power(power, t) 24 | else: 25 | self.logger.info('Ignoring packet of type {0}'.format(what.__class__.__name__)) 26 | 27 | def _process_create_game(self, what, t): 28 | eid, taglist = (what.game_entity.id, 29 | [(t.name, t.value) for t in what.game_entity.tags]) 30 | if eid in t: 31 | print('INFO: Game Entity already exists, ignoring "create game" event') 32 | return 33 | 34 | logging.debug('Got game entity:\n{0}'.format(what.game_entity)) 35 | taglist.append((TAG_CUSTOM_NAME, 'TheGame')) 36 | t.add(Entity(eid, taglist)) 37 | 38 | for player in what.players: 39 | eid, taglist = (player.entity.id, 40 | [(t.name, t.value) for t in player.entity.tags]) 41 | 42 | # TODO: are we interested in the battlenet id? 43 | logging.debug('Found Player {0}:\n{1}'.format(player.id, player)) 44 | taglist.append((TAG_CUSTOM_NAME, 'Player{0}'.format(player.id))) 45 | t.add(Entity(eid, taglist)) 46 | 47 | def _process_power(self, power, t): 48 | if power.HasField('full_entity'): 49 | e = power.full_entity 50 | taglist = [(e.name, e.value) for e in e.tags] 51 | taglist.append((TAG_POWER_NAME, e.name)) 52 | new_entity = Entity(e.entity, taglist) 53 | t.add(new_entity) 54 | 55 | # logging 56 | logger.info('Adding new entity: {0}'.format(new_entity)) 57 | logger.debug('With tags: \n' + '\n'.join( 58 | '\ttag {0}:{1} {2}'.format(tag_id, format_tag_name(tag_id), 59 | format_tag_value(tag_id, tag_val)) 60 | for tag_id, tag_val in taglist)) 61 | if power.HasField('show_entity'): 62 | e = power.show_entity 63 | mut = t.get_mutable(e.entity) 64 | mut[TAG_POWER_NAME] = e.name 65 | 66 | for tag in e.tags: 67 | mut[tag.name] = tag.value 68 | 69 | logger.info('Revealing entity: {0}'.format(mut)) 70 | if power.HasField('hide_entity'): 71 | pass 72 | if power.HasField('tag_change'): 73 | change = power.tag_change 74 | e = t.get_mutable(change.entity) 75 | 76 | logger.info('Tag change for {0}: {1} from {2} to {3}'.format( 77 | Entity.__str__(e), 78 | format_tag_name(change.tag), 79 | format_tag_value(change.tag, e[change.tag]) if e[change.tag] is not None else '(unset)', 80 | format_tag_value(change.tag, change.value))) 81 | 82 | e[change.tag] = change.value 83 | if power.HasField('create_game'): 84 | self._process_create_game(power.create_game, t) 85 | if power.HasField('power_start'): 86 | pass 87 | if power.HasField('power_end'): 88 | pass 89 | if power.HasField('meta_data'): 90 | pass 91 | -------------------------------------------------------------------------------- /hearthy/tracker/world.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from hearthstone.enums import GameTag 3 | from hearthy import exceptions 4 | from hearthy.protocol.utils import format_tag_value 5 | from hearthy.tracker.entity import Entity, MutableEntity, MutableView 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | class WorldTransaction: 10 | def __init__(self, world): 11 | self._world = world 12 | self._e = {} 13 | 14 | def add(self, entity): 15 | if isinstance(entity, Entity): 16 | assert entity.id not in self 17 | 18 | # New entities may be modified until transaction 19 | # completes 20 | entity.__class__ = MutableEntity 21 | 22 | self._e[entity.id] = entity 23 | 24 | def __contains__(self, eid): 25 | return eid in self._e or eid in self._world 26 | 27 | def get_mutable(self, eid): 28 | e = self._e.get(eid, None) 29 | if e is None: 30 | e = self._e[eid] = MutableView(self._world[eid]) 31 | return e 32 | 33 | def __getitem__(self, eid): 34 | e = self._e.get(eid, None) 35 | if e is None: 36 | return self._world[eid] 37 | return e 38 | 39 | def __enter__(self): 40 | return self 41 | 42 | def __exit__(self, exc_type, exc_value, traceback): 43 | if exc_type is None and exc_value is None and traceback is None: 44 | # TODO: overkill checking all three? 45 | self._world._apply(self) 46 | 47 | class World: 48 | """ 49 | Container for all in-game entities. 50 | """ 51 | def __init__(self): 52 | self._e = {} 53 | self._watchers = [] 54 | self.cb = None 55 | 56 | def __contains__(self, eid): 57 | return eid in self._e 58 | 59 | def __getitem__(self, eid): 60 | e = self._e.get(eid, None) 61 | if e is None: 62 | raise exceptions.EntityNotFound(eid) 63 | return e 64 | 65 | def __iter__(self): 66 | for entity in self._e.values(): 67 | yield entity 68 | 69 | def transaction(self): 70 | return WorldTransaction(self) 71 | 72 | def _apply(self, transaction): 73 | cb = self.cb 74 | if cb is not None: 75 | cb(self, 'pre_apply', transaction) 76 | 77 | for entity in transaction._e.values(): 78 | if GameTag.TURN in entity._tags: 79 | logger.info('== Turn {0} =='.format(entity._tags[GameTag.TURN])) 80 | 81 | if isinstance(entity, MutableView): 82 | entity._e._tags.update(entity._tags) 83 | else: 84 | assert entity.id not in self 85 | self._e[entity.id] = entity.freeze() 86 | -------------------------------------------------------------------------------- /hearthy/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HearthSim/Hearthy/ebce3779ee51a0d94f9def5df11f3523bd7fc4c5/hearthy/ui/__init__.py -------------------------------------------------------------------------------- /hearthy/ui/common.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import queue 3 | import sys 4 | import traceback 5 | 6 | from hearthy.datasource import hcapng 7 | from hearthy.protocol.utils import Splitter 8 | from hearthy.protocol.decoder import decode_packet 9 | 10 | MAX_QUEUE = 1000 11 | 12 | class Connection: 13 | __slots__ = ['p', '_s'] 14 | """ 15 | Represent a connection between two endpoints source and dest. 16 | Decodes packet in the connection 17 | """ 18 | def __init__(self, source, dest): 19 | self.p = [source, dest] 20 | self._s = [Splitter(), Splitter()] 21 | 22 | def feed(self, who, buf): 23 | for atype, abuf in self._s[who].feed(buf): 24 | decoded = decode_packet(atype, abuf) 25 | yield decoded 26 | 27 | def __repr__(self): 28 | print('>', self._on_tag_change) 23 | 24 | ftest = ttk.Combobox(self, textvariable=self.test, state='readonly') 25 | ftest['values'] = ('Exists', 'Not Exists', 'Equals', 'Not Equals') 26 | 27 | fvalue = ttk.Combobox(self, textvariable=self.value) 28 | b_remove = ttk.Button(self, text='Remove', command=self._on_remove) 29 | 30 | ctag.grid(row=0, column=0, sticky='nsew') 31 | ftest.grid(row=0, column=1, sticky='nsew') 32 | fvalue.grid(row=0, column=2, sticky='nsew') 33 | b_remove.grid(row=0, column=3, sticky='nsew') 34 | 35 | self.grid_rowconfigure(0, weight=1) 36 | self.grid_columnconfigure(0, weight=1) 37 | self.grid_columnconfigure(1, weight=1) 38 | self.grid_columnconfigure(2, weight=1) 39 | 40 | self._fvalue = fvalue 41 | 42 | self.cb = None 43 | 44 | def get_filter_string(self): 45 | tag = self.tag.get() 46 | test = self.test.get() 47 | value = self.value.get() 48 | 49 | tag_id = int(getattr(GameTag, tag)) 50 | 51 | if test == 'Exists': 52 | return '(x[{0}] is not None)'.format(tag_id) 53 | elif test == 'Not Exists': 54 | return '(x[{0}] is None)'.format(tag_id) 55 | 56 | enum = utils._gametag_to_enum.get(tag_id, None) 57 | if enum is not None: 58 | value = getattr(enum, value.upper(), value) 59 | 60 | try: 61 | value = int(value) 62 | except ValueError: 63 | print('Err: {0!r} is not numeric'.format(value)) 64 | return 65 | 66 | if test == 'Equals': 67 | return '(x[{0}] == {1})'.format(tag_id, value) 68 | elif test == 'Not Equals': 69 | return '(x[{0}] != {1})'.format(tag_id, value) 70 | 71 | def _on_remove(self): 72 | if self.cb is not None: 73 | self.cb(self, 'remove') 74 | 75 | def _on_tag_change(self, event): 76 | tag = self.tag.get() 77 | tag_id = int(getattr(GameTag, tag)) 78 | 79 | enum = utils._gametag_to_enum.get(tag_id, None) 80 | if enum is None: 81 | values = [] 82 | else: 83 | values = sorted(val.name.capitalize() for val in enum) 84 | 85 | self._fvalue['values'] = values 86 | 87 | class EntityTree: 88 | def __init__(self, container): 89 | self._build_widgets(container) 90 | self._world = None 91 | self._filter_fun = lambda x:True 92 | 93 | def _build_widgets(self, container): 94 | tree = ttk.Treeview(container, columns=('Info','Value')) 95 | tree.heading('#0', text='Name', anchor='w') 96 | tree.heading('#1', text='Info', anchor='w') 97 | tree.heading('#2', text='Value', anchor='w') 98 | 99 | vsb = ttk.Scrollbar(container, orient='vertical', command=tree.yview) 100 | tree.configure(yscrollcommand=vsb.set) 101 | 102 | tree.grid(column=0, row=0, sticky='nsew') 103 | vsb.grid(column=1, row=0, sticky='ns') 104 | 105 | container.grid_columnconfigure(0, weight=1) 106 | container.grid_rowconfigure(0, weight=1) 107 | 108 | self._tree = tree 109 | 110 | def _add_entity(self, entity): 111 | name = 'Entity {0}'.format(entity.id) 112 | node = self._tree.insert('', 'end', str(entity.id), text=name, 113 | value=(str(entity), '')) 114 | 115 | pre = str(entity.id) + '.' 116 | for tag, value in entity._tags.items(): 117 | self._tree.insert(node, 'end', pre + str(tag), 118 | text=str(tag), 119 | value=(utils.format_tag_name(tag), 120 | utils.format_tag_value(tag, value))) 121 | 122 | def _change_entity(self, eview): 123 | pre = str(eview.id) + '.' 124 | update_parent = False 125 | 126 | 127 | in_tree = self._tree.exists(str(eview.id)) 128 | does_pass = self._filter_fun(eview) 129 | 130 | if not does_pass: 131 | if in_tree: 132 | self._tree.delete(str(eview.id)) 133 | return 134 | else: 135 | if not in_tree: 136 | self._add_entity(eview._e) 137 | 138 | for tag, value in eview._tags.items(): 139 | if tag < 0 or tag == 49 or tag == 50: 140 | update_parent = True 141 | if eview._e[tag] is None: 142 | # add tag 143 | self._tree.insert(str(eview.id), 'end', pre + str(tag), 144 | text=tag, 145 | value=(GameTag.reverse.get(tag, ''), 146 | utils.format_tag_value(tag, value))) 147 | else: 148 | # change tag 149 | self._tree.item(pre + str(tag), 150 | value=(GameTag.reverse.get(tag, ''), 151 | utils.format_tag_value(tag, value))) 152 | 153 | if update_parent: 154 | self._tree.item(str(eview.id), 155 | value=(str(eview), '')) 156 | 157 | def set_filter(self, fun): 158 | self._filter_fun = fun 159 | if self._world is not None: 160 | self.set_world(self._world) 161 | 162 | def set_world(self, world): 163 | # clear tree 164 | dellist = list(self._tree.get_children()) 165 | for item in dellist: 166 | self._tree.delete(item) 167 | 168 | # rebuild tree 169 | for entity in world: 170 | if self._filter_fun(entity): 171 | self._add_entity(entity) 172 | 173 | self._world = world 174 | 175 | def apply_transaction(self, transaction): 176 | tree = self._tree 177 | 178 | for entity in transaction._e.values(): 179 | if isinstance(entity, MutableView): 180 | # Entity Changes 181 | self._change_entity(entity) 182 | else: 183 | # New Entity 184 | if self._filter_fun(entity): 185 | self._add_entity(entity) 186 | 187 | class EntityBrowser: 188 | def __init__(self): 189 | self._build_widgets() 190 | self._filters = [] 191 | self.cb = None 192 | self._f = lambda x:True 193 | 194 | def _on_destroy(self): 195 | if self.cb is not None: 196 | self.cb(self, 'destroy') 197 | self._window.destroy() 198 | 199 | def _build_widgets(self): 200 | self._window = parent = tkinter.Toplevel() 201 | parent.protocol('WM_DELETE_WINDOW', self._on_destroy) 202 | 203 | browser_frame = ttk.Labelframe(parent, text='Entity Browser') 204 | browser_frame.grid(row=0, column=0, sticky='nsew') 205 | 206 | tree = EntityTree(browser_frame) 207 | 208 | filter_frame = ttk.Labelframe(parent, text='Entity Filter') 209 | filter_frame.grid(row=1, column=0, sticky='nsew') 210 | 211 | parent.rowconfigure(0, weight=1) 212 | parent.columnconfigure(0, weight=1) 213 | 214 | button_frame = ttk.Frame(filter_frame) 215 | button_frame.pack(fill='x') 216 | 217 | b_apply = ttk.Button(button_frame, text='Apply Filter', command=self._apply_filter) 218 | b_apply.pack(side='left', expand=True, fill='x') 219 | 220 | b_add = ttk.Button(button_frame, text='Add Filter', command=self._add_filter) 221 | b_add.pack(side='left', expand=True, fill='x') 222 | 223 | self._tree = tree 224 | self._filter_frame = filter_frame 225 | self._button_frame = button_frame 226 | 227 | def _apply_filter(self): 228 | full = ' and '.join(filter(None, [ef.get_filter_string() for ef in self._filters])) 229 | if full: 230 | f = eval('lambda x: ' + full) 231 | else: 232 | f = lambda x:True 233 | self._tree.set_filter(f) 234 | 235 | def _remove_filter(self, ef, event): 236 | self._filters.remove(ef) 237 | ef.destroy() 238 | 239 | def _add_filter(self): 240 | efilter = EntityFilter(self._filter_frame) 241 | efilter.pack(expand=True, fill='x') 242 | efilter.cb = self._remove_filter 243 | 244 | self._filters.append(efilter) 245 | self._button_frame.pack_forget() 246 | self._button_frame.pack(fill='x') 247 | 248 | def set_world(self, world): 249 | self._tree.set_world(world) 250 | 251 | def apply_transaction(self, transaction): 252 | self._tree.apply_transaction(transaction) 253 | -------------------------------------------------------------------------------- /hearthy/ui/tk/streamlist.py: -------------------------------------------------------------------------------- 1 | import tkinter 2 | from tkinter import ttk 3 | from hearthy.tracker.processor import Processor 4 | 5 | from datetime import datetime 6 | 7 | from hearthy.ui.tk.entitybrowser import EntityBrowser 8 | from hearthy.ui.tk.streamview import StreamView 9 | 10 | class Stream: 11 | def __init__(self, stream_id, basets, start, source, dest): 12 | self.id = stream_id 13 | self.basets = basets 14 | self.source = source 15 | self.dest = dest 16 | self.packet_count = 0 17 | self.node = None 18 | self.status = 'open' 19 | self.start = start 20 | self.end = None 21 | self.packets = [] 22 | 23 | def get_values(self): 24 | source = '{0}:{1}'.format(*self.source) 25 | dest = '{0}:{1}'.format(*self.dest) 26 | start = end = '' 27 | 28 | if self.start is not None: 29 | start = datetime.fromtimestamp((self.start+self.basets)//1000).strftime('%Y.%m.%d %H:%M:%S') 30 | if self.end is not None: 31 | end = datetime.fromtimestamp((self.end+self.basets)//1000).strftime('%Y.%m.%d %H:%M:%S') 32 | 33 | return (self.packet_count, source, dest, 34 | start, end, self.status) 35 | 36 | class StreamList: 37 | def __init__(self, container): 38 | self._streams = {} 39 | self._container = container 40 | self._basets = 0 41 | self._build_widgets() 42 | self._stream_views = {} 43 | 44 | self._entity_browsers = {} 45 | self._trackers = {} 46 | 47 | def _streamview_cb(self, sv, event): 48 | if event == 'destroy': 49 | for l in self._stream_views.values(): 50 | if sv in l: 51 | l.remove(sv) 52 | 53 | def _entitybrowser_cb(self, eb, event): 54 | if event == 'destroy': 55 | to_delete = [] 56 | for world, l in self._entity_browsers.items(): 57 | if eb in l: 58 | l.remove(eb) 59 | 60 | def _world_cb(self, world, event, *args): 61 | if event == 'pre_apply': 62 | for eb in self._entity_browsers.get(world, []): 63 | eb.apply_transaction(*args) 64 | 65 | def open_entity_browser(self): 66 | sid = self.get_selected() 67 | if sid is None: 68 | return 69 | 70 | tracker = self._trackers.get(sid, None) 71 | if tracker is None: 72 | stream = self._streams[sid] 73 | tracker = self._trackers[sid] = Processor() 74 | 75 | assert tracker._world.cb is None 76 | tracker._world.cb = self._world_cb 77 | 78 | for packet in stream.packets: 79 | tracker.process(packet[1], packet[0]) 80 | 81 | world = tracker._world 82 | l = self._entity_browsers.get(world, None) 83 | if l is None: 84 | l = self._entity_browsers[world] = [] 85 | 86 | eb = EntityBrowser() 87 | eb.cb = self._entitybrowser_cb 88 | l.append(eb) 89 | 90 | eb.set_world(tracker._world) 91 | 92 | def open_stream_view(self): 93 | sid = self.get_selected() 94 | if sid is None: 95 | return 96 | 97 | stream = self._streams[sid] 98 | sv = StreamView(stream.id, stream.start) 99 | sv.cb = self._streamview_cb 100 | 101 | l = self._stream_views.get(sid, None) 102 | if l is None: 103 | l = self._stream_views[sid] = [] 104 | l.append(sv) 105 | 106 | for packet in stream.packets: 107 | sv.process_packet(*packet) 108 | 109 | def get_selected(self): 110 | s = self._view.selection() 111 | if s: 112 | sid = int(self._view.item(s[0], 'text').split(' ')[1]) 113 | return sid 114 | 115 | def _build_widgets(self): 116 | view = ttk.Treeview(self._container, 117 | columns=('n', 'Source', 'Dest', 'Start', 'End', 'Status')) 118 | view.heading('#0', text='Name', anchor='w') 119 | view.heading('#1', text='N', anchor='w') 120 | view.heading('#2', text='Source', anchor='w') 121 | view.heading('#3', text='Dest', anchor='w') 122 | view.heading('#4', text='Start', anchor='w') 123 | view.heading('#5', text='End', anchor='w') 124 | view.heading('#6', text='Status', anchor='w') 125 | 126 | view.column('#0', width=150, stretch=True) 127 | view.column('#1', width=50, stretch=False) 128 | view.column('#2', width=150, stretch=False) 129 | view.column('#3', width=150, stretch=False) 130 | view.column('#4', width=150, stretch=False) 131 | view.column('#5', width=150, stretch=False) 132 | view.column('#6', width=150, stretch=False) 133 | 134 | view.pack(fill='both', expand=True) 135 | 136 | self._view = view 137 | 138 | def on_create(self, stream_id, source, dest, ts): 139 | assert stream_id not in self._streams 140 | stream = Stream(stream_id, self._basets, ts, source, dest) 141 | self._streams[stream_id] = stream 142 | self._update_view(stream) 143 | 144 | def on_basets(self, ts): 145 | self._basets = ts 146 | 147 | def on_packet(self, stream_id, packet, who, ts): 148 | stream = self._streams.get(stream_id, None) 149 | assert stream is not None 150 | stream.packet_count += 1 151 | stream.packets.append((packet, who, ts)) 152 | self._update_view(stream) 153 | 154 | for sv in self._stream_views.get(stream_id, []): 155 | sv.process_packet(packet, who, ts) 156 | 157 | tracker = self._trackers.get(stream_id, None) 158 | if tracker is not None: 159 | tracker.process(who, packet) 160 | 161 | def on_close(self, stream_id, ts): 162 | stream = self._streams.get(stream_id, None) 163 | assert stream is not None 164 | stream.status = 'closed' 165 | stream.end = ts 166 | self._update_view(stream) 167 | 168 | def _update_view(self, stream): 169 | if stream.node is None: 170 | stream.node = self._view.insert('', 'end', 171 | text='Stream {0}'.format(stream.id), 172 | values=stream.get_values()) 173 | else: 174 | self._view.item(stream.node, values=stream.get_values()) 175 | -------------------------------------------------------------------------------- /hearthy/ui/tk/streamview.py: -------------------------------------------------------------------------------- 1 | import tkinter 2 | from tkinter import ttk 3 | 4 | def _append_node(tree, node, key, value): 5 | def add_sub(elements, summary=None): 6 | if summary is None: 7 | summary = '({0} element{1})'.format(len(elements), '' if len(elements) == 1 else 's') 8 | subnode = tree.insert(node, 'end', text=key, value=(summary, '')) 9 | for subkey, subval in elements: 10 | _append_node(tree, subnode, subkey, subval) 11 | if isinstance(value, str): 12 | tree.insert(node, 'end', text=key, value=(value, '')) 13 | elif isinstance(value, int): 14 | tree.insert(node, 'end', text=key, value=(value, '')) 15 | elif hasattr(value, 'ListFields'): 16 | add_sub([(d.name, v) for d, v in value.ListFields()], value.__class__.__name__) 17 | else: 18 | add_sub([('[{0}]'.format(i), value[i]) for i in range(len(value))]) 19 | 20 | class StreamView: 21 | def __init__(self, stream_id, start): 22 | self._n_packets = 0 23 | self.cb = None 24 | self._start = start 25 | self._stream_id = stream_id 26 | self._build_widgets() 27 | 28 | def _on_destroy(self): 29 | if self.cb is not None: 30 | self.cb(self, 'destroy') 31 | self._window.destroy() 32 | 33 | def _build_widgets(self): 34 | self._window = parent = tkinter.Toplevel() 35 | parent.title('Stream {0}'.format(self._stream_id)) 36 | parent.protocol('WM_DELETE_WINDOW', self._on_destroy) 37 | 38 | tree = ttk.Treeview(parent, columns=('Value', 'Time')) 39 | tree.heading('#0', text='Name', anchor='w') 40 | tree.heading('#1', text='Value', anchor='w') 41 | tree.heading('#2', text='Time', anchor='w') 42 | 43 | tree.column('#0', stretch=True) 44 | tree.column('#1', stretch=False) 45 | tree.column('#2', stretch=False) 46 | 47 | tree.tag_configure(0, background='#F0A57D') 48 | tree.tag_configure(1, background='#7DC8F0') 49 | 50 | vsb = ttk.Scrollbar(parent, orient='vertical', command=tree.yview) 51 | tree.configure(yscrollcommand=vsb.set) 52 | 53 | tree.grid(column=0, row=0, sticky='nsew') 54 | vsb.grid(column=1, row=0, sticky='ns') 55 | 56 | parent.grid_columnconfigure(0, weight=1) 57 | parent.grid_rowconfigure(0, weight=1) 58 | 59 | self._tree = tree 60 | 61 | def process_packet(self, packet, who, ts): 62 | name = packet.__class__.__name__ 63 | time = '{0:0.2f}s'.format((ts-self._start)/1000) 64 | 65 | tree = self._tree 66 | node = tree.insert( 67 | '', 'end', 68 | text='Packet {0}'.format(self._n_packets), 69 | value=(name, time), 70 | tags=(who,)) 71 | self._n_packets += 1 72 | 73 | for desc, subval in packet.ListFields(): 74 | _append_node(tree, node, desc.name, subval) 75 | -------------------------------------------------------------------------------- /hearthy/ui/tkmain.py: -------------------------------------------------------------------------------- 1 | import tkinter 2 | from tkinter import ttk 3 | 4 | from hearthy.ui.tk.streamlist import StreamList 5 | from hearthy.ui.common import AsyncLogGenerator 6 | from hearthy.datasource import hcapng 7 | 8 | class Application(ttk.Frame): 9 | def __init__(self, master=None): 10 | super().__init__(master) 11 | self.pack(expand=True, fill='both') 12 | self._build_widgets() 13 | self._streams = StreamList(self._streams_frame) 14 | 15 | def _build_widgets(self): 16 | self._b_packets = ttk.Button(self, text='View Packets', command=self._on_log_view) 17 | self._b_entities = ttk.Button(self, text='Entity Browser', command=self._on_entity_browser) 18 | self._streams_frame = ttk.LabelFrame(self, text='Stream List') 19 | 20 | self._streams_frame.grid(columnspan=2, row=0, column=0, sticky='nsew') 21 | self._b_packets.grid(row=1, column=0, sticky='nsew') 22 | self._b_entities.grid(row=1, column=1, sticky='nsew') 23 | 24 | self.grid_columnconfigure(0, weight=1) 25 | self.grid_columnconfigure(1, weight=1) 26 | self.grid_rowconfigure(0, weight=1) 27 | 28 | def _on_entity_browser(self): 29 | self._streams.open_entity_browser() 30 | 31 | def _on_log_view(self): 32 | self._streams.open_stream_view() 33 | 34 | def process_event(self, stream_id, event): 35 | if event[0] == 'packet': 36 | self._streams.on_packet(stream_id, *event[1:]) 37 | elif event[0] == 'create': 38 | self._streams.on_create(stream_id, *event[1:]) 39 | elif event[0] == 'close': 40 | self._streams.on_close(stream_id, *event[1:]) 41 | elif event[0] == 'basets': 42 | self._streams.on_basets(event[1]*1000) 43 | elif event[0] == 'exception': 44 | ex, = event[1:] 45 | logging.error( 46 | 'Exception while parsing event; data will be incomplete\n%s', 47 | ''.join(ex).rstrip('\n') 48 | ) 49 | 50 | if __name__ == '__main__': 51 | import sys 52 | import os 53 | import logging 54 | 55 | logging.basicConfig(level=logging.DEBUG) 56 | 57 | if len(sys.argv) < 2: 58 | print('Usage: {0} '.format(sys.argv[0])) 59 | sys.exit(1) 60 | 61 | root = tkinter.Tk() 62 | root.geometry('800x300') 63 | root.wm_title('MainWindow') 64 | 65 | parser = hcapng.AsyncParser() 66 | log_generator = AsyncLogGenerator() 67 | 68 | app = Application(master=root) 69 | 70 | fd = os.open(sys.argv[1], os.O_NONBLOCK | os.O_RDONLY) 71 | 72 | def read_cb(fd, mask): 73 | buf = os.read(fd, 1024) 74 | 75 | if len(buf) == 0: 76 | # end of file (hopefully) 77 | root.tk.deletefilehandler(fd) 78 | return 79 | 80 | try: 81 | for ts, event in parser.feed_buf(buf): 82 | for packet_event in log_generator.process_event(ts, event): 83 | app.process_event(*packet_event) 84 | except: 85 | root.tk.deletefilehandler(fd) 86 | raise 87 | 88 | root.tk.createfilehandler(fd, tkinter.READABLE, read_cb) 89 | root.mainloop() 90 | 91 | os.close(fd) 92 | -------------------------------------------------------------------------------- /helper/hcapture.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | #include 7 | 8 | #include 9 | #include 10 | #include 11 | 12 | #include "nids.h" 13 | 14 | #define int_ntoa(x) inet_ntoa(*((struct in_addr *)&x)) 15 | 16 | /* Start Platform Specific Functions */ 17 | #ifdef __MACH__ 18 | #include 19 | #include 20 | typedef clock_serv_t time_context_t; 21 | 22 | static 23 | int monotonic_init(time_context_t* ctx) { 24 | return host_get_clock_service(mach_host_self(), SYSTEM_CLOCK, ctx); 25 | } 26 | 27 | static 28 | void monotonic_destroy(time_context_t* ctx) { 29 | mach_port_deallocate(mach_task_self(), *ctx); 30 | } 31 | 32 | static 33 | int64_t monotonic_get(time_context_t* ctx) { 34 | mach_timespec_t mts; 35 | clock_get_time(*ctx, &mts); 36 | return ((int64_t)mts.tv_sec) * 1000 + (mts.tv_nsec / 1000000); 37 | } 38 | #elif __GNUC__ 39 | typedef int time_context_t; 40 | 41 | static 42 | int monotonic_init(time_context_t* ctx) { return 0; } 43 | 44 | static 45 | void monotonic_destroy(time_context_t* ctx) {} 46 | 47 | static 48 | int64_t monotonic_get(time_context_t* ctx) { 49 | struct timespec ts; 50 | if (clock_gettime(CLOCK_MONOTONIC_RAW, &ts) != 0) { 51 | return INT64_MIN; 52 | } 53 | return ((int64_t)ts.tv_sec) * 1000 + (ts.tv_nsec / 1000000); 54 | } 55 | #else 56 | 57 | #error "Unsupported platform" 58 | 59 | #endif 60 | /* End Platform Speific Functions */ 61 | 62 | #define EV_NEW_CONNECTION 0 63 | #define EV_CLOSE 1 64 | #define EV_DATA 2 65 | 66 | #define REPORT_HEADER_VERSION "HCaptureV0" 67 | 68 | void 69 | print_usage(char *name, FILE *out) { 70 | fprintf(out, "Usage: %s [-i iface] \n", name); 71 | } 72 | 73 | struct conn_params { 74 | int stream_id; 75 | }; 76 | 77 | #define MAX_BUF 1024 78 | 79 | FILE* outfile = NULL; 80 | int stream_count = 0; 81 | uint8_t header_buf[MAX_BUF]; 82 | int64_t reference_ts; 83 | time_context_t monotonic_time; 84 | 85 | unsigned int 86 | write_uint64(uint8_t *buf, unsigned int offset, uint64_t arg) { 87 | buf[offset++] = arg & 0xff; arg >>= 8; 88 | buf[offset++] = arg & 0xff; arg >>= 8; 89 | buf[offset++] = arg & 0xff; arg >>= 8; 90 | buf[offset++] = arg & 0xff; arg >>= 8; 91 | buf[offset++] = arg & 0xff; arg >>= 8; 92 | buf[offset++] = arg & 0xff; arg >>= 8; 93 | buf[offset++] = arg & 0xff; arg >>= 8; 94 | buf[offset++] = arg & 0xff; 95 | return offset; 96 | } 97 | 98 | unsigned int 99 | write_uint32(uint8_t *buf, unsigned int offset, unsigned int arg) { 100 | buf[offset++] = arg & 0xff; arg >>= 8; 101 | buf[offset++] = arg & 0xff; arg >>= 8; 102 | buf[offset++] = arg & 0xff; arg >>= 8; 103 | buf[offset++] = arg & 0xff; 104 | return offset; 105 | } 106 | 107 | unsigned int 108 | write_uint16(uint8_t *buf, unsigned int offset, unsigned int arg) { 109 | buf[offset++] = arg & 0xff; arg >>= 8; 110 | buf[offset++] = arg & 0xff; 111 | return offset; 112 | } 113 | 114 | unsigned int 115 | write_uint8(uint8_t *buf, unsigned int offset, unsigned int arg) { 116 | buf[offset++] = arg & 0xff; 117 | return offset; 118 | } 119 | 120 | unsigned int 121 | write_relative_timestamp(uint8_t *buf, unsigned int offset) { 122 | int64_t diff = monotonic_get(&monotonic_time) - reference_ts; 123 | return write_uint64(buf, offset, (uint64_t)diff); 124 | } 125 | 126 | void 127 | report_write_header(int64_t timestamp) { 128 | unsigned int offset = sizeof(REPORT_HEADER_VERSION); 129 | strcpy((char*)header_buf, REPORT_HEADER_VERSION); 130 | offset = write_uint64(header_buf, offset, (uint64_t)timestamp); 131 | 132 | // TODO: check for errors 133 | fwrite(header_buf, 1, offset, outfile); 134 | fflush(outfile); 135 | } 136 | 137 | void 138 | report_new_connection(struct conn_params * params, struct tuple4 * addr) { 139 | unsigned int offset = 4; 140 | offset = write_relative_timestamp(header_buf, offset); 141 | offset = write_uint8(header_buf, offset, EV_NEW_CONNECTION); 142 | offset = write_uint32(header_buf, offset, params->stream_id); 143 | offset = write_uint32(header_buf, offset, ntohl(addr->saddr)); 144 | offset = write_uint16(header_buf, offset, addr->source); 145 | offset = write_uint32(header_buf, offset, ntohl(addr->daddr)); 146 | offset = write_uint16(header_buf, offset, addr->dest); 147 | write_uint32(header_buf, 0, offset); 148 | 149 | // TODO: check for errors 150 | fwrite(header_buf, 1, offset, outfile); 151 | fflush(outfile); 152 | } 153 | 154 | void 155 | report_close(struct conn_params * params) { 156 | unsigned int offset = 4; 157 | offset = write_relative_timestamp(header_buf, offset); 158 | offset = write_uint8(header_buf, offset, EV_CLOSE); 159 | offset = write_uint32(header_buf, offset, params->stream_id); 160 | write_uint32(header_buf, 0, offset); 161 | 162 | // TODO: check for errors 163 | fwrite(header_buf, 1, offset, outfile); 164 | fflush(outfile); 165 | } 166 | 167 | void 168 | report_data(struct conn_params * params, int who, char * buf, int count) { 169 | unsigned int offset = 4; 170 | offset = write_relative_timestamp(header_buf, offset); 171 | offset = write_uint8(header_buf, offset, EV_DATA); 172 | offset = write_uint32(header_buf, offset, params->stream_id); 173 | offset = write_uint8(header_buf, offset, who); 174 | write_uint32(header_buf, 0, offset + count); 175 | 176 | // TODO: check for errors 177 | fwrite(header_buf, 1, offset, outfile); 178 | fwrite(buf, 1, count, outfile); 179 | fflush(outfile); 180 | } 181 | 182 | void 183 | tcp_callback(struct tcp_stream *a_tcp, void ** param) { 184 | struct conn_params * params = *param; 185 | if (params == NULL && a_tcp->nids_state != NIDS_JUST_EST) { 186 | printf("Can this even happen?\n"); 187 | return; 188 | } 189 | 190 | if (a_tcp->nids_state == NIDS_JUST_EST) { 191 | *param = NULL; 192 | 193 | printf("Source: %s:%u\n", int_ntoa(a_tcp->addr.saddr), a_tcp->addr.source); 194 | printf("Dest: %s:%u\n", int_ntoa(a_tcp->addr.daddr), a_tcp->addr.dest); 195 | 196 | if (a_tcp->addr.dest == 1119 || a_tcp->addr.dest == 3724) { 197 | params = malloc(sizeof(struct conn_params)); 198 | if (params == NULL) { 199 | fprintf(stderr, "Warn: malloc failed!\n"); 200 | } else { 201 | *param = params; 202 | params->stream_id = stream_count++; 203 | 204 | report_new_connection(params, &a_tcp->addr); 205 | 206 | a_tcp->client.collect++; 207 | a_tcp->server.collect++; 208 | 209 | printf("Started recording stream id=%d\n", params->stream_id); 210 | } 211 | } 212 | } else if (a_tcp->nids_state == NIDS_CLOSE || a_tcp->nids_state == NIDS_RESET) { 213 | printf("Stream id=%d closed\n", params->stream_id); 214 | report_close(params); 215 | free(params); 216 | } else if (a_tcp->nids_state == NIDS_DATA) { 217 | if (a_tcp->client.count_new) { 218 | report_data(params, 1, a_tcp->client.data, a_tcp->client.count_new); 219 | } else if (a_tcp->server.count_new) { 220 | report_data(params, 0, a_tcp->server.data, a_tcp->server.count_new); 221 | } else { 222 | printf("Warning: NIDS_DATA occured without new data\n"); 223 | } 224 | } 225 | } 226 | 227 | struct { 228 | char * device; 229 | char * error; 230 | char * outfn; 231 | int showhelp; 232 | } opts; 233 | 234 | void 235 | parse_opts(char *argv[]) { 236 | char * cur = *argv; 237 | char * next = NULL; 238 | 239 | while (cur != NULL) { 240 | next = *(++argv); 241 | 242 | if (strcmp(cur, "-i") == 0) { 243 | if (next == NULL) { 244 | opts.error = "Options -i requires an argument!"; 245 | return; 246 | } 247 | opts.device = next; 248 | next = *(++argv); 249 | } else { 250 | if (opts.outfn == NULL) { 251 | opts.outfn = cur; 252 | } else { 253 | opts.error = "Trailing positional argument"; 254 | return; 255 | } 256 | } 257 | cur = next; 258 | } 259 | if (opts.outfn == NULL) { 260 | opts.error = "Output filename is required"; 261 | } 262 | } 263 | 264 | int 265 | main(int argc, char *argv[]) { 266 | // initialize options 267 | opts.device = NULL; 268 | opts.error = NULL; 269 | opts.outfn = NULL; 270 | opts.showhelp = 0; 271 | 272 | // parse options 273 | parse_opts(argv+1); 274 | 275 | if (opts.error) { 276 | fprintf(stderr, "Error: %s\n", opts.error); 277 | print_usage(argv[0], stderr); 278 | exit(1); 279 | } 280 | 281 | // Open output file 282 | outfile = fopen(opts.outfn, "wb"); 283 | if (outfile == NULL) { 284 | perror("fopen"); 285 | return 1; 286 | } 287 | 288 | // Initialize nids 289 | nids_params.device = opts.device; 290 | nids_params.promisc = 0; 291 | if (!nids_init()) { 292 | fprintf(stderr, "%s\n", nids_errbuf); 293 | return 1; 294 | } 295 | 296 | // Disable checksum checking 297 | struct nids_chksum_ctl nochksumchk; 298 | nochksumchk.netaddr = 0; 299 | nochksumchk.mask = 0; 300 | nochksumchk.action = NIDS_DONT_CHKSUM; 301 | 302 | nids_register_chksum_ctl(&nochksumchk, 1); 303 | 304 | // Get unix timestamp and monotonic timestamp 305 | int64_t timestamp = (int64_t)time(NULL); 306 | 307 | if (monotonic_init(&monotonic_time) != 0) { 308 | fprintf(stderr, "Failed to initialize monotonic time\n"); 309 | return 1; 310 | } 311 | 312 | reference_ts = monotonic_get(&monotonic_time); 313 | 314 | if (reference_ts == INT64_MIN) { 315 | perror("clock_gettime"); 316 | return 1; 317 | } 318 | 319 | // write report header 320 | report_write_header(timestamp); 321 | 322 | // Start main loop 323 | nids_register_tcp(tcp_callback); 324 | nids_run(); 325 | 326 | monotonic_destroy(&monotonic_time); 327 | fclose(outfile); 328 | return 0; 329 | } 330 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e git+https://github.com/HearthSim/python-hearthstone.git#egg=hearthstone 2 | py3-protobuffers>=3.0.0a4 3 | -------------------------------------------------------------------------------- /screenshots/entitybrowser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HearthSim/Hearthy/ebce3779ee51a0d94f9def5df11f3523bd7fc4c5/screenshots/entitybrowser.png -------------------------------------------------------------------------------- /screenshots/streamview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HearthSim/Hearthy/ebce3779ee51a0d94f9def5df11f3523bd7fc4c5/screenshots/streamview.png --------------------------------------------------------------------------------