Реверс-инжиниринг «Казаков 3», часть сетевая: создаём локальный сервер
Недавно в разговоре с коллегами обсуждали различные игры жанра RTS, и я задумался, почему же релиз третьих «Казаков» прошёл мимо меня. Пару минут и один поисковый запрос спустя я вспомнил — помимо крайне сырого раннего релиза, реинкарнация этой классической стратегии отличилась невозможностью многопользовательской игры без постоянного соединения с официальным сервером. Многочисленные просьбы игроков «добавить LAN» на форумах разной степени свежести намекают, что изменений ждать не стоит.
Что ж, если гора не идёт к Магомету…
TL; DR: Сервер, инструкция по применению и исходный код доступны на GitHub.
Первые шаги
Для того, чтобы понять общую структуру протокола, вполне достаточно первых четырёх пакетов, подслушанных между клиентом игры и официальным сервером. Итак, мы имеем:
- Сетевой протокол, построенный поверх обычного TCP/IP,
- без шифрования или обфускации,
- в котором числа передаются в порядке от младшего к старшему.
Заголовок пакета всегда занимает ровно 14 байт и содержит размер полезной нагрузки (1), командный код (2) и два идентификатора игроков для адресации пакетов (3,4). Возьмём простой пример — личное сообщение в чате игрового лобби:
Здесь также видно, что строкам предшествует их длина (5). Примечательно то, что в зависимости от конкретного командного кода, формат данных различается и размер строк указывается то одним, то двумя, а то и четырьмя байтами.
Рассмотрим публичное оповещение о создании новой игровой комнаты:
Заголовок так же начинается с размера, команды и отправителя (1,2,3), а вот идентификатор получателя отсутствует (4). В данном сообщении это значит «отправить всем», но для других команд может означать и «всем в своей комнате». Первая строка (5) содержит название, пароль и информацию о типе игры и версии клиента у создателя (хоста) комнаты. Для разделения значений используется знак табуляции \t, он же 09h. Затем следует строка с информацией о комнате (6), которая требуется для её отображения в списке. Она содержит статус, количество живых игроков, компьютерных противников, закрытых слотов и ещё два значения. Здесь роль разделителя играет вертикальная черта. После неё следуют две константы размером по 4 байта (далее — число типа Int), затем строка с именем хоста компьютера создателя комнаты (7).
- Да, пароли к частным комнатам передаются всем игрокам на сервере.
(См. также статью «У «Казаков» секретов нет») - Нет, имя хоста создателя комнаты не требуется для игры, т.к. все пакеты передаются через сервер.
А теперь перейдём к более интересным моментам.
Трудности перевода
Изначально я анализировал пакеты в Wireshark с фильтрамиtcp && data, чтобы перед глазами не мелькали «пустые» пакеты типа ACK. В какой-то момент мне это вышло боком: выяснилось, что Wireshark ошибочно принимает пакеты, полезная нагрузка TCP которых начинается с байтов 05h 00h, за пакеты протокола DCERPC. В частности, это коснулось пакетов с оповещением о входе игрока в комнату, т.к. они всегда содержат ровно пять байтов после заголовка. Это приводит к тому, что Wireshark помечает нагрузку TCP не как Data, а как [Malformed Packet: DCERPC] и скрывает пакет:
Правильным в данном случае было бы применение более нового фильтра tcp.payload. Он отображает все пакеты TCP с полезной нагрузкой, вне зависимости от того, как Wireshark эту нагрузку интерпретирует.
Эволюция сериализации
При реверсе сетевого протокола было очевидно, что над ним в разное время работали разные люди, имеющие различные приоритеты. Можно выделить три вида строк, передающих переменные:
- строки компактные
Длина строки указывается одним байтом, строки содержат названия переменных, поля разделяются вертикальными чертами:ps=1337|pw=42|pg=12
- строки промежуточные
Длина строки указывается двумя байтами (далее — число типа Short), значения разделяются знаком табуляции, названия отсутствуют:"MyRoom"\t"secret"\t0AFFE
- строки щедрые
Особые строки, которые на самом деле являются массивом строк с чередующимися названиями переменных и их и значениями. Эдакое подобие ассоциативного массива в одномерном виде. Длина каждой (sic!) из строк в нём указывается числом типа Int. Детальное описание формата можно найти в комментариях к исходному коду сервера.Пример
Красным отмечены числа, указывающие длины строк, жёлтым — разделители.
Последний пример взят из пакета, который регулирует транзит роли хоста на одного из игроков при выходе создателя комнаты из игры. Насколько я понимаю, этот функционал дорабатывали после релиза игры. Очевидно, что тогда вопрос об эффективности передачи данных уже не стоял так остро, как на момент написания сетевого протокола в оригинальном движке.
Небрежные буфера
В конце многих пакетов полезная нагрузка завершается четырьмя или шестью нулевыми байтами. Но в определённых пакетах (в частности, с командными кодами 0xc8 и 0×19d) среди них иногда неожиданно всплывают данные. Я никак не мог понять, откуда они берутся и зачем нужны, пока в одном из таких пакетов не обнаружил в них фрагмент одного из сообщений в чате лобби.
По всей видимости, официальный сервер не всегда обнуляет буфер, в который пишет ответный пакет перед отправкой, и в заключающих байтах могут затеряться остатки прошлых пакетов. К счастью, в самом клиенте игры это не приводит к ошибкам. Но кое-что о темпах разработки и уровне контроля качества это всё же говорит.
Меньше знаешь — крепче сервер
…или «чем хуже, тем лучше». После набора критической массы знаний о используемом протоколе, объём исходного кода перестал расти и начал таять. Интерфейсы упрощались, а данные, которые сервер вынужден сохранять (например, для передачи состояния лобби новоприбывшим игрокам), перестали анализироваться и стали храниться в виде строк. Не обязательно знать происхождение и назначение каждого байта в пакете; достаточно понимать, как и кому его перенаправить.
На большинство запросов сервер отвечает данными, полученными от других клиентов ранее, или же провоцирует нужного клиента специальным пакетом и перенаправляет ответ запросившему. Где возможно, я опускал необязательные строки, заменяя их нулевым байтом. В первую очередь это касается данных о рейтинге игроков, количестве очков и побед.
В отдельных случаях слишком широкая ретрансляция пакетов даже порождала ошибки на стороне клиента. В частности, я заметил это на паре «запрос — ответ» с командными кодами 0×1b3 и 0×1b4, дублирующими информацию об очках игрока и системе клиента.
Становитесь в очередь
Отдельную сложность для меня представил аспект запуска и синхронизации игры. Разобравшись с ретрансляцией пакетов при загрузке, я заметил, что во время игры все клиенты посылают пакеты с командным кодом 0×4b0. При этом в одном поле идентификатора стоит номер создателя комнаты, а второе поле пустует. Но если подходить логически и понимать это как «клиент — всем в указанной комнате», то возникает рассинхронизация игры.
Вместо этого сервер должен сам следить за источником пакета и проверять, хост это или обычный игрок. В первом случае пакет отправляется всем игрокам в комнате кроме самого хоста, в последнем — исключительно хосту. При этом пакет с игровой командой, видимо, вносится в очередь происходящего и затем рассылается обратно уже от хоста, но в контексте всех остальных событий игры. Это гарантирует одинаковый порядок исполнения команд у всех игроков. Хост рассылает игровые пакеты постоянно и в определённом такте, вне зависимости от количества событий, а все остальные — только при действиях со стороны игрока.
Кстати, именно поэтому при смене хоста во время игры показывается предупреждение о том, что ваши приказы могут быть потеряны — если старый хост не успел внести их в общую очередь перед отключением, то у нового хоста нет никакой возможности узнать об этих командах.
Рудиментарный LAN
Вопреки общественному мнению, в клиенте всё же присутствуют функции, необходимые для полноценной игры в локальной сети. При анализе сетевого трафика я заметил, что среди многочисленных пакетов TCP создатель комнаты также рассылает уведомления о количестве игроков и статусе комнаты через UDP.
Помимо этого, при дизассемблировании клиента я наткнулся на функцию, которая вызывается при возникновении исключений и отображает текст ошибки. Пройдясь по её вызовам и анализируя передаваемый ей текст можно определить настоящие названия тех процедур, в которых возникают исключения. Немного удивившись результату, я поигрался с strings,
function LanPublicServerGetRegIDFrom
function LanPublicServerGetRegIDTo
function LanPublicServerGetRegMessage
function LanPublicServerGetClientTeamByIndex
function LanPublicServerGetClientTeamByClientID
function LanPublicServerGetClientSpecByIndex
function LanPublicServerGetClientSpecByClientID
function LanPublicServerGetClientInfoToParserByIndex
function LanPublicServerGetClientInfoToParserByClientID
function LanPublicServerGetSessionInfoToParserByIndex
function LanPublicServerGetSessionInfoToParserByClientID
function LanPublicServerGetClientsCount
function LanPublicServerGetSessionsCount
function LanPublicServerGetClientIndexByClientID
function LanPublicServerGetClientIndexByClientNick
function LanPublicServerGetSessionIndexByClientID
function LanPublicServerProfScore
function LanPublicServerProfCountry
function LanPublicServerProfGamesPlayed
function LanPublicServerProfGamesWin
function LanPublicServerProfLastGameTime
function LanPublicServerProfInfo
function LanMyInfoHost
function LanMyInfoIP
function LanMyInfoID
function LanMyInfoSpec
function LanMyInfoName
function LanMyInfoPlayer
function LanGetServerInfoToParser
function LanIpToString
function LanIpToInt
function LanGetClientsCount
function LanGetClientIDByIndex
function LanGetClientHostByIndex
function LanGetClientNameByIndex
function LanGetClientSpecByIndex
function LanGetClientIndexByID
function LanGetClientPlayerNameByIndex
function LanSelectParser
function LanGetParserID
function LanGetSendDataThreadCount
function LanGetSendDataThreadEnabled
function LanGetNoDelayOption
function LanGetOptimizedPackage
function LanGetOptimizedPackageDef
Тут два варианта: либо это у разработчиков юмор такой, либо они повсеместно использовали аббревиатуру LAN для всего, что связано с многопользовательской игрой.
C++, Asio и невредимые ноги
Так как я изначально ставил для себя цель написать кроссплатформенный сервер и расширить познания в сетевом программировании, то для реализации я выбрал язык C++ и библиотеку Asio. Последняя также позволила мне отказаться от многопоточности и связанных с ней особенностей доступа к данным в пользу асинхронности и более простого кода. За основу мною был взят исходный код одного из примеров в репозитории библиотеки.
Наиболее интересным для меня аспектом разработки стала проблема доступности буфера данных во время асинхронной отправки пакетов. В это же время я стремился минимизировать количество аллокаций и копирования данных в памяти. В дополнение ко всему, сервер должен иметь довольно большой буфер для приёма пакетов, т.к. размер полезной нагрузки TCP при передаче данных о карте перед стартом игры может превышать 800 килобайт.
В итоге я реализовал процесс чтения, создания и отправления пакетов следующим образом:
- При подключении нового клиента сервер создаёт объект класса Session, содержащий достаточно большой (1 MiB) буфер типа std: vector
(далее — Buffer). - После завершения операции асинхронного чтения в этот буфер его адрес передаётся главной функции обработки пакетов. Следующая операция чтения будет инициирована лишь после завершения этой функции, гарантируя сохранность данных в буфере на время обработки.
- В начале обработки создаётся объект класса Packet, предоставляющий интерфейс для чтения и сериализации данных. Через него, ответ сервера пишется всё в тот же буфер объекта Session, который закреплён за отправителем пакета.
- После того, как все операции над пакетом завершены, функция отправления аллоцирует буфер с помощью std: make_shared
. При этом конструктору Buffer передаётся итератор всё того же буфера в Session, учитывая точный размер ответного пакета (за этим во время записи следит Packet). Т.е. в рамках одной операции мы сначала аллоцируем достаточно памяти для пакета и контрольного блока указателя (причём за один раз), затем копируем в неё ровно то количество байт из большого буфера, которое должно быть отправлено. - Новый буфер передаётся с помощью полученного указателя типа std: shared_ptr
(далее — BufPtr) объектам Session всех тех клиентов, которым должен быть отправлен пакет. Там он помещается в локальные очереди типа std: deque . Каждая копия указателя, находящаяся в очереди у какого-либо из клиентов, увеличивает счётчик ссылок. После этого обработка пакета заканчивается, и первоначальный буфер Session готов принять следующий пакет. - После завершения операции асинхронной записи (отправления) пакета указатель стирается из локальной очереди Session клиента-адресата, понижая счётчик ссылок. Как только пакет будет отправлен всем клиентам, у которых он был в очереди, счётчик обнулится и умный указатель самостоятельно освободит память.
Как ни суди, а добавление в стандарт языка C++ лямбда-выражений, разнообразных контейнеров и умных указателей значительно снизило сложность проектирования подобных систем; при написании сервера ни одной ноги прострелено не было.
Тест на выдержку
Закончив работу над сервером и опробовав основные функции, я решил проверить стабильность сервера и синхронизацию после транзита хоста спустя длительное время. Для этого я создал в локальной сети комнату с тремя игроками и с пятью сложнейшими ИИ. Для теста я выбрал максимальные параметры касательно размера карты, количества месторождений, населения и времени ненападения. Я запустил игру и через пару минут остановил в диспетчере задач процесс игры на компьютере хоста, чтобы симулировать неожиданное отключение. Затем два оставшихся клиента были предоставлены самим себе на всю ночь.
Опытные игроки, наверное, уже догадались, что было дальше. Если говорить коротко, то сервер прошёл тест, а вот клиент — нет. На следующий день экран приветствовал меня застывшим около восьми часов игровым таймером, а так же несколькими сообщениями об ошибках, среди которых было и всем знакомое «Out of memory». Всё логично, два оставшихся ИИ разделили карту пополам и сражались многотысячными армиями. Процесс игры занял около 3,5 ГБ оперативной памяти и упёрся в свои 32-битные границы. Сервер же, в свою очередь, продолжал работать на своих 11 МБ оперативной памяти.
Заключение
Надеюсь, вам было интересно проследить за процессом копирования «чёрного ящика» официального сервера. Также я надеюсь, что разработчики игры сжалятся над игроками и наконец-то добавят возможность многопользовательской игры в локальной сети, с блэкджеком и с быстрым UDP и без необходимости иметь низкий пинг до Хетцнера. Последний аргумент, кстати, заиграл новыми красками ввиду недавних событий.
Если есть вопросы или предложения касательно реверса или исходного кода сервера — добро пожаловать в комментарии. До новых встреч!