На дворе конец 2016 года, наконец-то, вызвав бурю восторга среди фанатов, вышла третья часть «Казаков»… А мне всё не давала покоя странная ошибка в сетевой компоненте первой части. Странность заключалась в том, что при создании игры в локальной сети нормально запустить игру могли только два человека. При трёх игроках индикатор загрузки рос мучительно медленно, а начиная с четырёх и вовсе оставался на отметке 0%. Что ж, начнём расследование!
Проявление ошибки
Симптомов у проблемы сразу несколько. Значение «max ping», которое отображается в игровой комнате, слишком высоко и растёт пропорционально количеству игроков. Хотя все игроки подключены к одному коммутатору и обычный icmp ping выдаёт стабильно меньше 1 мс, в игровой комнате отображаются задержки вплоть до 350 мс. Если в комнате всего два игрока, то «max ping» сначала равен ~90 мс, затем падает посекундно до ~10 мс.
Второй симптом это отображение предупреждения «no direct connection established with: имя игрока». Втроём шанс получить его где-то 50%, а если в комнате четыре игрока, то оно показывается постоянно. Хотя это предупреждение и можно игнорировать, удерживая клавишу Ctrl при нажатии на «Start», это указывает на некоторую «проблему восприятия» качества соединения со стороны игры.
Третий симптом это медленная скорость загрузки. Когда все игроки нажали «Start», у хоста игры отображается индикатор в процентах. Он увеличивается шагами по ~8%, достигает 100% и лишь после этого можно начать игру. Учитывая то, что этот индикатор в первую очередь отображает прогресс передачи файла случайно созданной карты от хоста к игрокам, а размер этого файла для обычной карты равен примерно 3,5 МБ, то даже 5 секунд загрузки в гигабитной локальной сети являются проблемой. А за то время, которое требуется для «загрузки» трёх игроков можно скопировать всю папку с игрой.
Начинаем с конца
С вашего позволения я избавлю вас от подробностей начала этого реверса и возникших проблем. Скажу только, что я ошибался, думая, что разбирать сетевую составляющую будет весело. Вызов функций через указатели. Многопоточность. Работа с sendto() и recvfrom() одновременно с использованием DirectPlay. Отсутствие какой-либо внятной документации к этому динозавру, не говоря уже об отладочной информации. Моего кунг-фу здесь было явно недостаточно.
Так что будем работать по старинке. Открываем всеми любимую, наипрекраснейшую программу, нажимаем Alt+T и ищем «no direct connection». Находим регион памяти, содержащий строку, затем через xref выходим прямо на указатель, а затем и на объёмную функцию размером в 32 килобайта. Среди прочего в ней инициализируются элементы интерфейса игровой комнаты, обрабатываются сообщения и компонуются строки перед выдачей на экран. Назовём eё LanLobby(). Ниже вы можете увидеть алгоритм принятия решения, показывать ли предупреждение игроку и деактивировать ли клавишу «Start»:
Примечание: изображённые в статье листинги дизассемблера были обработаны для улучшения читаемости.
- Вызывается небольшая функция-цикл SomeIteration(). Забегая вперёд скажу, что в её теле присутствует вызов похожей функции, назовём её ImportantIteration(). Если последняя хотя бы один раз возвращает ноль, то SomeIteration() тут же выходит из цикла и также возвращает ноль. В этом случае переход не совершается и мы рискуем получить предупреждение.
- Далее проверяется состояние кнопки Ctrl. Если она зажата, то кнопка «Start» не будет отключена.
- Деактивация кнопки «Start». Переменная hStartButton инициализируется выше результатом функции с говорящим названием «addVideoButton».
- Проверяется таймер относительно прошествия двух секунд. Выше переменной PreviousTick присваивается текущее значение GetTickCount() каждый раз, когда меняется количество игроков в комнате. Если с того момента прошло менее двух секунд, то совершается переход и предупреждение не выводится.
- В конце концов, строка с предупреждением копируется в строку, предназначенной для вывода на экран в качестве строки состояния игровой комнаты.
Вывод: После принятия игрока в комнату игра даёт себе две секунды, чтобы наладить соединение. Затем, если ImportantIteration() до сих пор возвращает ноль, показывается предупреждение отсутствия прямого соединения.
Камень преткновения
На этом этапе реверса мне ещё не было понятно, что же такого происходит в ImportantIteration() и я решил найти компоновку строки индикатора загрузки и дальше работать оттуда. Ранее, в тщетной попытке анализировать алгоритм вычисления «max ping» я заметил, что все строки с числовыми переменными сначала компонуются через sprintf(), а затем помещаются в целевую строку. Учитывая синтаксис форматной строки ищем текст «%%» и находим совпадение в нашей LanLobby(). Так выглядит отрывок кода, решающий, показывать ли в поле индикатора загрузки число с процентами или галочку, сигнализирующую «готовность» игры:
Получается, что результат функции GetLoadPercentage(), если он меньше 100, один в один переносится на экран. Вторая ветка, должно быть, рисует «галочку». Интересно, что же такого высчитывается в этой функции? GetLoadPercentage() состоит из цикла, который проходит по массиву с данными игроков и… опа!
И тут ImportantIteration() решает вопрос. Да, не ошиблись мы с именем. Учитывая арифметику, ImportantIteration() возвращает статус загрузки игрока в диапазоне от 0x00 до 0x0C, то есть на этой «шкале» всего 12 шагов. Теперь понятно, почему процентный индикатор увеличивается шагами по ~8%.
Теперь, когда мы поняли, что ImportantIteration() должна делать, посмотрим, где она ещё используется.
Кроме известных нам вызовов при проверке качества соединения и расчёте процентов ImportantIteration() вызывается в двух случаях: при формулировании предупреждения о плохом соединении — там она используется, чтобы составить список игроков для строки состояния — и для проверки, все ли игроки готовы. Последнее осуществляется в функции с небольшим циклом, который проходит по всем игрокам и сравнивает степень загрузки с 0x0C. Если хоть у одного игрока она меньше, то цикл возвращает ноль. Можно смело предположить, что результат последней функции напрямую влияет на активацию кнопки «Start» у хоста и возможность начать игру.
Решение
Итак, самое простое решение это внести исправления в логику, зависящую от результата ImportantIteration(). Благо во всех случаях переходы осуществляются при позитивном результате, то есть нам нужно лишь изменить все переходы с условных на безусловные. В случае с так называемой «Windows 7 версией» файла dmcr.exe весь патч можно описать так:
оффсет | было | стало | эффект
---------+-----------+-----------+-----------------------------
0x00CEEA | 0x7D | 0xEB | игра всегда готова к старту
0x098792 | 0x0F 0x8D | 0x90 0xE9 | у всех игроков всегда 100%
0x09C389 | 0x0F 0x85 | 0x90 0xE9 | нет проверки соединения
А так как все изменения касаются только логики игровой комнаты для игр в локальной сети, а само сетевое сообщение происходит параллельно и не зависит от неё, то можно не опасаться побочных эффектов. Файл случайно созданной карты раздаётся по сети почти моментально, а комната позволяет запустить игру без всяких задержек. Тут можно и руки умыть, но…
Что же в ларце?
Ведь любопытно же, каким образом игровая комната определяет статус загрузки игроков. Встречайте ImportantIteration(), героя сегодняшней статьи:
Тут можно подметить несколько интересных вещей:
- Один из двух параметров передаётся через регистр ecx.
- Все нужные данные находятся в памяти рядом друг с другом, так что зачем нам хранить их адреса? Вместо указателей Pointer_A и Pointer_B у нас будут Pointer_A и (Pointer_A + 4).
- Регион памяти, на который указывает параметр-указатель PlayersDataStruct, пишется в параллельных потоках и никак не зависит от происходящего в LanLobby().
При попытке понять, что же такого творится в указанном регионе памяти было обнаружено нечто, похожее на массив из структур. Каждый элемент имеет размер в 0x84 байта, а внутри хранятся среди прочего имя игрока, имя используемого файла случайной карты, версия клиента игры, значение пинга, несколько переменных булевого типа, а также несколько целочисленных значений и/или указателей. Все попытки отследить записи в этом регионе упираются в вызовы memcpy() из других потоков, а статически анализировать его довольно сложно — xref выдаёт 82 ссылки, и кроме инициализации значением 0x12345678 все из них на чтение. Т.е. адрес структуры загружается в регистры, проводятся вычисления адреса нужного элемента, и только после этого чтение, запись, вызов других функций с указателем в качестве переменной, или всё выше перечисленное сразу.
В конце концов я просто поставил слежение на запись в определённую часть этой структуры, а в теле ImportantIteration() добавил несколько условных точек останова. Собственно остановку отладчика я отключил, а в качестве условия указал небольшую IDC-функцию, выводящую содержимое регистра в окно сообщений:
Message("EDX: %08Xn", EDX), 0
После этого я запустил игру и на несколько секунд подключился к хосту в локальной сети. Затем я остановил отладчик и, просмотрев результаты слежения и содержимое окна сообщений («Trace window» и «Output window»), пришёл к следующему выводу:
Регион памяти, из которого извлекает данные среди прочего и ImportantIteration(), постоянно меняется. При этом некоторые значения перед записью новых данных сначала обнуляются. Вероятно, где-то при обработке сетевых сообщений перед сохранением новой информации этот регион памяти сначала заново инициализируется. А так как у нас DirectPlay и многопоточность, то эти нули вполне могут оказаться там как раз во время исполнения нашего цикла, что и приводит к описанному в начале статьи поведению.
Послесловие
Итак, какую мораль можно извлечь из сей басни? Не нужно быть Якудза, чтобы заехать к Якудза Не всегда обязательно выкапывать корни проблемы, чтобы решить её. Кстати, в процессе реверса мне на глаза абсолютно случайно попался кусок кода, отвечающий за «авто-проигрыш» при свёртывании игры на более, чем пару секунд. Так что теперь можно спокойно менять музыку во время игры. Правда, я не уверен на счёт пользы такого патча для игрового сообщества, так как он, возможно, облегчит манипулирование игрой с помощью сторонних программ.
Напоследок хотелось бы вернуться к вопросу о тайминге игры, поднятым в первой статье. Тогда я не до конца разобрался с ролью функций QueryPerformanceFrequency() и QueryPerformanceCounter(). Как верно подметил Андрей Smi1e, было не похоже, чтобы они влияли на тайминг. Сейчас я могу точно сказать, что эти функции используется в игровой комнате первых «Казаков» как генератор псевдослучайных чисел для создании файла случайной карты и/или имени этого файла, а собственно тайминг игры осуществляется исключительно через GetTickCount().
На этом всё, до новых встреч!
Ссылки
- Несколько статей об устройстве сетевых игр (англ.)
- Официальная документация DirectPlay на MSDN (англ.)
Автор: Ereb