[Из песочницы] Реверс-инжиниринг и замедление «Казаков»
В известной игре «Казаки: Снова Война» присутствует баг, сводящий удовольствие от сетевой игры к нулю: Нечеловеческая скорость игрового процесса на современных компьютерах. При этом изменение скорости игры в настройках, прекрасно работающее в режиме одиночной игры, никак не влияет на происходящее в игре по сети. Этот вопрос обсуждается на множестве форумов, но самые популярные советы это:
- Искусственно загрузить ядро процессора, на котором запущена игра
- Запускать игру в виртуальной машине с ограниченными ресурсами
- Играть не по локальной сети, а по интернету — там задержки побольше
Первые два варианта приводят к тому, что игра идёт медленно, но с рывками. Качество звука при этом тоже падает. Третий вариант вообще без комментариев.
Приступаем
Для начала поищем значение настройки скорости с помощью Cheat Engine. Это должен быть либо некий мультипликатор, прямо пропорциональный положению ползунка в меню настроек, либо обратно пропорциональный ему интервал. Довольно быстро находится вот эта ячейка памяти:
Этот интервал равен 0 при максимальной скорости, а комфортная для меня скорость игры соответствует интервалу 20. Изменение значения во время игры тут же отражается на скорости игрового процесса. Хорошо, посмотрим, что там у нас в сетевой игре. Загружаемся, меняем значение в Cheat Engine, и… ничего. Меняется только положение ползунка в настройках. Ладно, посмотрим где этот интервал обрабатывается. Cheat Engine показывает лишь два соседних адреса, на которых происходит чтение ячейки:
Посмотрим на листинг встроенного дизассемблера:
Навскидку можно сказать, что интервал удваивается и сравнивается с чем-то, и если это что-то меньше удвоенного интервала, то совершается переход куда-то назад. Отлично, запускаем всеми любимую для таких дел татарскую программу и видим такую картину в самом конце одной длинной функции:
После некоторого копания выясняем следующее:
- dword_718410 — величина интервала согласно настройкам скорости
- sub_54C1BE — процедура, выполняющая GetTickCount
- dword_83B1A4 — результат предыдущего GetTickCount
- loc_4D1ABF — субпроцедура, сравнивающая разницу двух результатов GetTickCount и прыгающая назад, если разница меньше интервала
Ищем ошибку
Ставим точку останова где-нибудь в субпроцедуре сравнения с интервалом. Запускаем одиночную игру и сразу же вылетаем в отладчик. Запускаем игру по сети — точка останова не срабатывает. При этом если поставить точку останова в самом начале функции, то она срабатывает всегда. Выходит, что именно в многопользовательской игре сознательно не выполняется проверка интервала. Дело осталось за малым: Найти ответственное за это разветвление в функции и изменить его так, чтобы всегда выполнялась ветвь с нужной нам субпроцедурой loc_4D1ABF.
Будем идти снизу вверх. Для начала поставим точку останова в субпроцедуре loc_4D1A9C. Бинго! В одиночной игре переменная word_611B60 всегда равна 1, так что условие прыжка не выполняется и управление передаётся сначала в loc_4D1AAA, а оттуда уже нашей субпроцедуре. При игре по сети переменная word_611B60 всегда равна 2, что приводит к прыжку вперёд сразу к loc_4D1AE6 и к концу функции. Чтобы заставить игру всегда передавать управление на ветку с субпроцедурой loc_4D1ABF достаточно заменить инструкцию сравнения cmp edx, 2 на cmp edx, 3. Как два байта об асфальт!
Не всё так просто
Теперь настройка скорости работает и в сетевой игре. К сожалению, без ложки дёгтя не обошлось: Со временем у одного из игроков начинает сильно увеличиваться скорость скроллинга и темп анимации воды. Через какое-то время эффект пропадает и появляется у другого игрока. При этом скорость остальных игровых процессов у обоих одинакова и соответствует установленной в настройках.
Причину такого поведения выяснить мне не удалось, но возникло сильное подозрение на субпроцедуру loc_4D1AAA, вызывающую ProcessMessages. По всей видимости для игры по сети этот вызов не был нужен. Возможно именно из-за выше описанного странного поведения был сделан обход этой субпроцедуры? Во всяком случае попробуем исключить её из ветки, оставив только полезную нам субпроцедуру loc_4D1ABF.
Достаём калькулятор
Итак, что нам нужно сделать:
- Заменить прыжок с условием в субпроцедуре loc_4D1A9C на короткий прямой прыжок в субпроцедуру loc_4D1ABF
- Изменить смещение обратного прыжка в субпроцедуре loc_4D1ABF, чтобы замкнуть её на себя и не попадать в loc_4D1AAA
- Убрать вторую инструкцию прыжка к loc_4D1AAA, находящуюся в блоке сразу после субпроцедуры loc_4D1ABF
С первым пунктом всё понятно: Операционный код короткого прыжка это EB, а нужное нам смещение определяется вычитанием адреса следующего за инструкцией прыжка байта из адреса начала субпроцедуры loc_4D1A9C.
С третьим пунктом ещё проще: Заменяем инструкцию прыжка двумя nop'ами.
Второй же пункт требует вычисления смещения для короткого прыжка назад. К счастью я наткнулся на статью, понятно описывающую алгоритм этого действия, а именно: Вычесть адрес назначения из адреса следующего за инструкцией прыжка байта, затем вычесть 1h, после этого перевести число в бинарный вид (в размере байта), инвертировать и снова перевести в шестнадцатеричную систему. Полученное число и есть нужное нам смещение.
Ну что ж, господа. Патчим!
Результат
Открываем изменённый файл в всё той же всеми любимой программе и видим следующую картину:
Блок, содержащий вызов ProcessMessages никогда не будет исполнен. После выключенной нами проверки на многопользовательскую игру в субпроцедуре loc_4D1A9C управление переходит в нашу субпроцедуру с вызовом GetTickCount и сравнением с интервалом. Если разница меньше интервала, то субпроцедура прыгает обратно в начало самой себя до тех пор, пока интервал не будет соблюдён.
Теперь игра ведёт себя так, как надо. Скорость игры соответствует наименьшей скорости среди игроков, синхронизация не нарушается. Скорость скроллинга тоже поддаётся настройке.
Послесловие
Так как это мой первый опыт реверс-инжиниринга и работы с ассемблером, то скорее всего это не самое элегантное решение. Корень проблемы кроется в использовании функций QueryPerformanceFrequency и QueryPerformanceCounter, на которых основывается тайминг игры. Эти функции вызываются один раз при создании новой игры, задавая тон для всех последующих вычислений с GetTickCount. К сожалению, у меня не получилось повлиять на этот участок программы должным образом.Ссылки