diff --git a/ff_draw/gui/panel.py b/ff_draw/gui/panel.py index 813e41a..a2b82a5 100644 --- a/ff_draw/gui/panel.py +++ b/ff_draw/gui/panel.py @@ -5,7 +5,6 @@ import glfw import glm import imgui -from ff_draw import plugins if typing.TYPE_CHECKING: from . import Drawing @@ -43,7 +42,7 @@ def ffd_page(self): else: self.territory = f'{territory.region.text_sgl}-{territory.sub_region.text_sgl}-{territory.area.text_sgl}' imgui.text(f'territory: {self.territory}#{tid}') - imgui.text(f'pos: {me.pos}#{me.facing/math.pi:.2f}pi') + imgui.text(f'pos: {me.pos}#{me.facing / math.pi:.2f}pi') else: imgui.text(f'me: N/A') @@ -78,6 +77,23 @@ def ffd_page(self): if clicked: sniffer.config['print_packets'] = sniffer.print_packets self.main.save_config() + clicked, sniffer.dump_pkt = imgui.checkbox("dump_pkt", sniffer.dump_pkt) + if clicked: + sniffer.config['dump_pkt'] = sniffer.dump_pkt + sniffer.update_dump() + self.main.save_config() + if sniffer.dump_pkt: + clicked, sniffer.dump_zone_down_only = imgui.checkbox("dump_zone_down_only", sniffer.dump_zone_down_only) + if clicked: + sniffer.config['dump_zone_down_only'] = sniffer.dump_zone_down_only + self.main.save_config() + + imgui.text('func_parser') + parser = self.main.parser + clicked, parser.print_compile = imgui.checkbox("print_compile", parser.print_compile) + if clicked: + parser.compile_config.setdefault('print_debug', {})['enable'] = parser.print_compile + self.main.save_config() def draw(self): if not self.is_show: @@ -87,9 +103,8 @@ def draw(self): if glfw.get_window_attrib(self.window, glfw.ICONIFIED): return window_flag = 0 - if not self.is_expand: - window_flag |= imgui.WINDOW_NO_MOVE - self.is_expand, self.is_show = imgui.begin('FFDPanel', True, flags=window_flag) + if not self.is_expand: window_flag |= imgui.WINDOW_NO_MOVE + self.is_expand, self.is_show = imgui.begin('FFDPanel', True, window_flag) glfw.set_window_size(self.window, *map(int, imgui.get_window_size())) if not self.is_expand: return imgui.end() win_pos = glm.vec2(*imgui.get_window_position()) diff --git a/ff_draw/main.py b/ff_draw/main.py index 26f6533..138621b 100644 --- a/ff_draw/main.py +++ b/ff_draw/main.py @@ -18,7 +18,8 @@ else: use_aiohttp_cors = True -from . import gui, omen, mem, func_parser, plugins, update, sniffer +from . import gui, omen, mem, func_parser, plugins, update +from .sniffer import sniffer_main as sniffer default_cn = bool(os.environ.get('DefaultCn')) diff --git a/ff_draw/sniffer/__init__.py b/ff_draw/sniffer/__init__.py index f50ce6b..e69de29 100644 --- a/ff_draw/sniffer/__init__.py +++ b/ff_draw/sniffer/__init__.py @@ -1,140 +0,0 @@ -import logging -import multiprocessing -import os -import pathlib -import threading -import typing -from nylib.utils import serialize_data, KeyRoute, BroadcastHook -from nylib.utils.win32.network import find_process_tcp_connections -from . import enums, sniffer, extra -from .message_structs import zone_server, zone_client, chat_server, chat_client -from .utils import message, structs, simple, bundle, oodle - -if typing.TYPE_CHECKING: - from ff_draw.main import FFDraw - - -class GameMessageBuffer: - def __init__(self, oodle_type: typing.Type[oodle.Oodle]): - self.oodle = oodle_type() - self.buffer = bytearray() - - def feed(self, data: bytes): - self.buffer.extend(data) - yield from bundle.decode(self.buffer, self.oodle) - - -class Sniffer: - target: tuple[str, int] = None - logger = logging.getLogger('Sniffer') - - def __init__(self, main: 'FFDraw'): - self.main = main - self.ipc_lock = threading.Lock() - - self.config = self.main.config.setdefault('sniffer', {}) - self.print_packets = self.config.setdefault('print_packets', False) - self.print_actor_control = self.config.setdefault('print_actor_control', False) - self.sniff_promisc = self.config.setdefault('sniff_promisc', True) - - pno_dir = pathlib.Path(os.environ['ExcPath']) / 'res' / 'proto_no' - self._chat_server_pno_map = simple.load_pno_map(pno_dir / 'ChatServerIpc.csv', self.main.mem.game_build_date, enums.ChatServer) - self._chat_client_pno_map = simple.load_pno_map(pno_dir / 'ChatClientIpc.csv', self.main.mem.game_build_date, enums.ChatClient) - self._zone_server_pno_map = simple.load_pno_map(pno_dir / 'ZoneServerIpc.csv', self.main.mem.game_build_date, enums.ZoneServer) - self._zone_client_pno_map = simple.load_pno_map(pno_dir / 'ZoneClientIpc.csv', self.main.mem.game_build_date, enums.ZoneClient) - - self.on_chat_server_message = KeyRoute(lambda m: m.proto_no) - self.on_chat_client_message = KeyRoute(lambda m: m.proto_no) - self.on_zone_server_message = KeyRoute(lambda m: m.proto_no) - self.on_zone_client_message = KeyRoute(lambda m: m.proto_no) - self.on_actor_control = KeyRoute(lambda m: m.id) - self.on_action_effect = BroadcastHook() - self.on_play_action_timeline = BroadcastHook() - - self.pipe, child_pipe = multiprocessing.Pipe() - self.sniff_process = multiprocessing.Process(target=sniffer.start_sniff, args=(child_pipe, self.sniff_promisc), daemon=True) - - self.packet_fix = self.main.mem.packet_fix - - main.gui.timer.add_mission(self.update_tcp_target, 1, -1) - main.gui.draw_update_call.add(self.update) - self.oodles = {} - self.buffers = {} - self.extra = extra.SnifferExtra(self) - - def update(self, main): - while self.pipe.poll(0): - cmd, *args = self.pipe.recv() - match cmd: - case 'ask_target': - self.pipe.send(('set_target', self.target)) - case 'tcp_data': - key, is_up, data = args - if (_k := (key, is_up)) not in self.buffers: - self.buffers[_k] = buffer = GameMessageBuffer( - oodle.OodleUdp - # oodle.OodleUdp - ) - else: - buffer = self.buffers[_k] - for msg in buffer.feed(data): - self._on_message(is_up, msg) - - def update_tcp_target(self): - cnt = set() - t = None - for local_host, local_port, remote_host, remote_port in find_process_tcp_connections(self.main.mem.pid): - if (t := (str(remote_host), remote_port)) in cnt: break - cnt.add(t) - t = None - if t != self.target: - self.buffers.clear() - self.target = t - self.pipe.send(('set_target', t)) - - def _on_message(self, is_up, msg: message.BaseMessage): - if msg.el_header.type == 3: - msg = msg.to_el(structs.IpcHeader) - self._on_ipc_message(msg.element.timestamp_s != 10, is_up, msg) - - def _on_ipc_message(self, is_zone, is_up, msg: message.ElementMessage[structs.IpcHeader]): - with self.ipc_lock: - if is_zone: - if is_up: - pno_map = self._zone_client_pno_map - call = self.on_zone_client_message - type_map = zone_client.type_map - else: - pno_map = self._zone_server_pno_map - call = self.on_zone_server_message - type_map = zone_server.type_map - else: - if is_up: - pno_map = self._chat_client_pno_map - call = self.on_chat_client_message - type_map = chat_client.type_map - else: - pno_map = self._chat_server_pno_map - call = self.on_chat_server_message - type_map = chat_server.type_map - - data = msg.raw_data - if (pno := msg.element.proto_no) in pno_map: - pno = pno_map[pno] - if t := type_map.get(pno): - msg = msg.to_ipc(t) - data = msg.message - if hasattr(t, '_pkt_fix'): - data._pkt_fix(self.packet_fix.value) - try: - evt = message.NetworkMessage(proto_no=pno, raw_message=msg, header=msg.el_header, message=data) - if self.print_packets: - source_name = getattr(self.main.mem.actor_table.get_actor_by_id(evt.header.source_id), 'name', None) - self.logger.debug(f'{"Zone" if is_zone else "Chat"}{"Client" if is_up else "Server"}[{pno}] {source_name}#{evt.header.source_id:x} {serialize_data(data)}') - # self.logger.debug(f'{is_zone} {is_up} {evt.proto_no} {call.route.get(evt.proto_no)}') - call(evt) - except Exception as e: - self.logger.error(f'error in processing network message {pno}', exc_info=e) - - def start(self): - self.sniff_process.start() diff --git a/ff_draw/sniffer/message_dump.py b/ff_draw/sniffer/message_dump.py new file mode 100644 index 0000000..6e21450 --- /dev/null +++ b/ff_draw/sniffer/message_dump.py @@ -0,0 +1,87 @@ +import json +import pathlib +import struct +import threading +import time + +from . import enums +from .message_structs import zone_server, zone_client, chat_server, chat_client +from .utils import simple + +empty_ipc = bytearray(16) + + +class MessageDumper: + _ver_ = 0 + + def __init__(self, file_name: pathlib.Path | str, game_build_date: str): + if isinstance(file_name, str): file_name = pathlib.Path(file_name) + assert not file_name.exists() + file_name.parent.mkdir(exist_ok=True, parents=True) + self.file_name = file_name + self.game_build_date = game_build_date + self.handle = open(file_name, 'wb', buffering=0) + self.handle.write(json.dumps(self.get_header(), ensure_ascii=False).encode('utf-8') + b'\n') + self.write_lock = threading.Lock() + + def get_header(self): + return { + 'dumper_version': self._ver_, + 'game_build_date': self.game_build_date, + 'start_log_time': int(time.time() * 1000), + } + + def write(self, timestamp_ms: int, is_zone: bool, is_up: bool, proto_no: int, source_id: int, data: bytes, fix_value=0): + to_write = struct.pack(b'BBHIQI',((int(is_zone) << 1) | int(is_up)) , fix_value, proto_no, source_id, timestamp_ms, len(data)) + data + with self.write_lock: self.handle.write(to_write) + + def close(self): + self.handle.close() + self.handle = None + + @classmethod + def parse(cls, file_name: pathlib.Path | str, pno_dir: pathlib.Path | str): + if isinstance(file_name, str): file_name = pathlib.Path(file_name) + if isinstance(pno_dir, str): pno_dir = pathlib.Path(pno_dir) + + assert file_name.exists() + with open(file_name, 'rb') as buf: + header = json.loads(buf.readline().decode('utf-8')) + assert isinstance(header, dict) and header['dumper_version'] == cls._ver_ + game_build_date = header['game_build_date'] + chat_server_pno_map = simple.load_pno_map(pno_dir / 'ChatServerIpc.csv', game_build_date, enums.ChatServer) + chat_client_pno_map = simple.load_pno_map(pno_dir / 'ChatClientIpc.csv', game_build_date, enums.ChatClient) + zone_server_pno_map = simple.load_pno_map(pno_dir / 'ZoneServerIpc.csv', game_build_date, enums.ZoneServer) + zone_client_pno_map = simple.load_pno_map(pno_dir / 'ZoneClientIpc.csv', game_build_date, enums.ZoneClient) + + while True: + if not (header_bytes := buf.read(20)): break + + scope, fix_value, proto_no, source_id, timestamp_ms, size = struct.unpack(b'BBHIQI', header_bytes) + data = buf.read(size) + + is_zone = scope & 0b10 > 0 + is_up = scope & 0b1 > 0 + if is_zone: + if is_up: + pno_map = zone_client_pno_map + type_map = zone_client.type_map + else: + pno_map = zone_server_pno_map + type_map = zone_server.type_map + else: + if is_up: + pno_map = chat_client_pno_map + type_map = chat_client.type_map + else: + pno_map = chat_server_pno_map + type_map = chat_server.type_map + if proto_no in pno_map: + proto_no = pno_map[proto_no] + if t := type_map.get(proto_no): + data = t.from_buffer_copy(data) + if hasattr(t, '_pkt_fix'): + data._pkt_fix(fix_value) + proto_no = proto_no.name + + yield is_zone, is_up, proto_no, source_id, timestamp_ms, data diff --git a/ff_draw/sniffer/sniffer_main.py b/ff_draw/sniffer/sniffer_main.py new file mode 100644 index 0000000..6be2f9f --- /dev/null +++ b/ff_draw/sniffer/sniffer_main.py @@ -0,0 +1,157 @@ +import logging +import multiprocessing +import os +import pathlib +import threading +import time +import typing +from nylib.utils import serialize_data, KeyRoute, BroadcastHook +from nylib.utils.win32.network import find_process_tcp_connections +from . import enums, sniffer, extra, message_dump +from .message_structs import zone_server, zone_client, chat_server, chat_client +from .utils import message, structs, simple, bundle, oodle + +if typing.TYPE_CHECKING: + from ff_draw.main import FFDraw + + +class GameMessageBuffer: + def __init__(self, oodle_type: typing.Type[oodle.Oodle]): + self.oodle = oodle_type() + self.buffer = bytearray() + + def feed(self, data: bytes): + self.buffer.extend(data) + yield from bundle.decode(self.buffer, self.oodle) + + +class Sniffer: + target: tuple[str, int] = None + logger = logging.getLogger('Sniffer') + dump: message_dump.MessageDumper | None = None + + def __init__(self, main: 'FFDraw'): + self.main = main + self.ipc_lock = threading.Lock() + + self.config = self.main.config.setdefault('sniffer', {}) + self.print_packets = self.config.setdefault('print_packets', False) + self.print_actor_control = self.config.setdefault('print_actor_control', False) + self.sniff_promisc = self.config.setdefault('sniff_promisc', True) + self.dump_pkt = self.config.setdefault('dump_pkt', False) + self.dump_zone_down_only = self.config.setdefault('dump_zone_down_only', True) + + pno_dir = pathlib.Path(os.environ['ExcPath']) / 'res' / 'proto_no' + self._chat_server_pno_map = simple.load_pno_map(pno_dir / 'ChatServerIpc.csv', self.main.mem.game_build_date, enums.ChatServer) + self._chat_client_pno_map = simple.load_pno_map(pno_dir / 'ChatClientIpc.csv', self.main.mem.game_build_date, enums.ChatClient) + self._zone_server_pno_map = simple.load_pno_map(pno_dir / 'ZoneServerIpc.csv', self.main.mem.game_build_date, enums.ZoneServer) + self._zone_client_pno_map = simple.load_pno_map(pno_dir / 'ZoneClientIpc.csv', self.main.mem.game_build_date, enums.ZoneClient) + + self.on_chat_server_message = KeyRoute(lambda m: m.proto_no) + self.on_chat_client_message = KeyRoute(lambda m: m.proto_no) + self.on_zone_server_message = KeyRoute(lambda m: m.proto_no) + self.on_zone_client_message = KeyRoute(lambda m: m.proto_no) + self.on_actor_control = KeyRoute(lambda m: m.id) + self.on_action_effect = BroadcastHook() + self.on_play_action_timeline = BroadcastHook() + + self.pipe, child_pipe = multiprocessing.Pipe() + self.sniff_process = multiprocessing.Process(target=sniffer.start_sniff, args=(child_pipe, self.sniff_promisc), daemon=True) + + self.packet_fix = self.main.mem.packet_fix + + main.gui.timer.add_mission(self.update_tcp_target, 1, -1) + main.gui.draw_update_call.add(self.update) + self.oodles = {} + self.buffers = {} + self.extra = extra.SnifferExtra(self) + + self.update_dump() + + def update_dump(self): + if self.dump: + self.dump.close() + self.dump = None + if self.dump_pkt: + file_name = self.main.app_data_path / 'dump_pkt' / time.strftime("dump_%Y_%m_%d_%H_%M_%S.dmp") + self.dump = message_dump.MessageDumper(file_name, self.main.mem.game_build_date) + self.logger.info(f'dump packets at {file_name}') + + def update(self, main): + while self.pipe.poll(0): + cmd, *args = self.pipe.recv() + match cmd: + case 'ask_target': + self.pipe.send(('set_target', self.target)) + case 'tcp_data': + key, is_up, data = args + if (_k := (key, is_up)) not in self.buffers: + self.buffers[_k] = buffer = GameMessageBuffer( + oodle.OodleUdp + ) + else: + buffer = self.buffers[_k] + for msg in buffer.feed(data): + self._on_message(is_up, msg) + + def update_tcp_target(self): + cnt = set() + t = None + for local_host, local_port, remote_host, remote_port in find_process_tcp_connections(self.main.mem.pid): + if (t := (str(remote_host), remote_port)) in cnt: break + cnt.add(t) + t = None + if t != self.target: + self.buffers.clear() + self.target = t + self.pipe.send(('set_target', t)) + + def _on_message(self, is_up, msg: message.BaseMessage): + if msg.el_header.type == 3: + msg = msg.to_el(structs.IpcHeader) + self._on_ipc_message(msg.element.timestamp_s != 10, is_up, msg) + + def _on_ipc_message(self, is_zone, is_up, msg: message.ElementMessage[structs.IpcHeader]): + fix_value = None + if self.dump and ((not self.dump_zone_down_only) or (is_zone and not is_up)): + self.dump.write(msg.bundle_header.timestamp_ms, is_zone, is_up, msg.element.proto_no, msg.el_header.source_id, msg.raw_data[16:], fix_value := self.packet_fix.value) + with self.ipc_lock: + if is_zone: + if is_up: + pno_map = self._zone_client_pno_map + call = self.on_zone_client_message + type_map = zone_client.type_map + else: + pno_map = self._zone_server_pno_map + call = self.on_zone_server_message + type_map = zone_server.type_map + else: + if is_up: + pno_map = self._chat_client_pno_map + call = self.on_chat_client_message + type_map = chat_client.type_map + else: + pno_map = self._chat_server_pno_map + call = self.on_chat_server_message + type_map = chat_server.type_map + + data = msg.raw_data + if (pno := msg.element.proto_no) in pno_map: + pno = pno_map[pno] + if t := type_map.get(pno): + msg = msg.to_ipc(t) + data = msg.message + if hasattr(t, '_pkt_fix'): + data._pkt_fix(self.packet_fix.value if fix_value is None else fix_value) + try: + evt = message.NetworkMessage(proto_no=pno, raw_message=msg, header=msg.el_header, message=data) + if self.print_packets: + source_name = getattr(self.main.mem.actor_table.get_actor_by_id(evt.header.source_id), 'name', None) + self.logger.debug(f'{"Zone" if is_zone else "Chat"}{"Client" if is_up else "Server"}[{pno}] {source_name}#{evt.header.source_id:x} {serialize_data(data)}') + # self.logger.debug(f'{is_zone} {is_up} {evt.proto_no} {call.route.get(evt.proto_no)}') + call(evt) + except Exception as e: + self.logger.error(f'error in processing network message {pno}', exc_info=e) + + def start(self): + self.sniff_process.start() diff --git a/version.txt b/version.txt index 7ada0d3..ac39a10 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.8.5 +0.9.0