Реверс-інжиніринг «Козаків», частина третя: наперстки LAN


На дворі кінець 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 кілобайти. Серед іншого в ній ініціалізуються елементи інтерфейсу ігрової кімнати, обробляються повідомлення і компонуються рядки перед видачею на екран. Назвемо її LanLobby(). Нижче ви можете побачити алгоритм прийняття рішення, показувати попередження гравцю і деактивувати чи клавішу «Start»:

Примітка: зображені у статті лістинги дизассемблера були оброблені для поліпшення читаності.

  1. Викликається невелика функція-цикл SomeIteration(). Забігаючи вперед скажу, що в її тілі присутній виклик схожою функції, назвемо її ImportantIteration(). Якщо остання хоча б один раз повертає нуль, то SomeIteration() тут же виходить з циклу і також повертає нуль. У цьому випадку перехід не здійснюється і ми ризикуємо отримати попередження.

  2. Далі перевіряється стан кнопки Ctrl. Якщо вона затиснута, то кнопка «Start» не буде відключена.

  3. Деактивація кнопки «Start». Змінна hStartButton ініціалізується вище результатом функції з промовистою назвою «addVideoButton».

  4. Перевіряється таймер щодо прошествия двох секунд. Вище змінної PreviousTick присвоюється поточне значення GetTickCount() кожен раз, коли змінюється кількість гравців в кімнаті. Якщо з того моменту пройшло менше двох секунд, то відбувається перехід і попередження не виводиться.

  5. зрештою, рядок з попередженням копіюється в рядок, призначеної для виведення на екран в якості рядка стану ігрової кімнати.

Висновок: Після прийняття гравця в кімнату гра дає собі дві секунди, щоб налагодити сполучення. Потім, якщо 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: %08X\n", EDX), 0

Після цього я запустив гру і на кілька секунд підключився до хосту в локальній мережі. Потім я зупинив відладчик і, переглянувши результати стеження і вміст вікна повідомлень («Trace window» і «Output window»), прийшов до наступного висновку:

Регіон пам'яті, з якого отримує дані серед іншого і ImportantIteration(), постійно змінюється. При цьому деякі значення перед записом нових даних спочатку обнуляються. Ймовірно, десь при обробці мережевих повідомлень перед збереженням нової інформації цей регіон пам'яті спочатку заново ініціалізується. А так як у нас DirectPlay і багатопоточність, то ці нулі цілком можуть виявитися там як раз під час виконання нашого циклу, що і призводить до описаного на початку статті поведінки.

Післямова
Отже, яку мораль можна витягти з цієї байки? Не потрібно бути Якудза, щоб заїхати до Якудза Не завжди обов'язково викопувати коріння проблеми, щоб вирішити її. До речі, в процесі реверсу мені на очі зовсім випадково попався шматок коду, що відповідає за «авто-програш» при згортанні гри на більше, ніж кілька секунд. Так що тепер можна спокійно міняти музику під час гри. Правда, я не впевнений на рахунок користі такого патча для ігрового співтовариства, так як він, можливо, полегшить маніпулювання грою з допомогою сторонніх програм.

Наостанок хотілося б повернутися до питання про таймінгу ігри, піднятим першої статті. Тоді я не до кінця розібрався з роллю функцій QueryPerformanceFrequency() і QueryPerformanceCounter(). Як вірно підмітив Андрій Smi1e, було не схоже, щоб вони впливали на таймінг. Зараз я можу точно сказати, що ці функції використовується в ігровій кімнаті перших «Козаків» як генератор псевдовипадкових чисел для створення файлу випадкової карти і/або імені файлу, а власне таймінг гри здійснюється виключно через GetTickCount().

На цьому все, до нових зустрічей!

Посилання
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.