Assembler
Game development
Reverse engineering
7 September 2015

Реверс-инжиниринг полёта Бэтмена



Этим летом вышла очередная игра из серии Batman Arkham, в ПК версии которой оказалось столько багов, что было принято беспрецедентное решение снять её с продаж. Я решил посмотреть, что же там такого ужасного.

Среди прочих наблюдается такой баг, на первый взгляд, случайный: иногда, когда Бэтмен прыгает с крыши, вместо того, чтобы ровно планировать, он колбасится некоторое время, потом делает довольно глубокий нырок, и только потом выравнивается. В результате в лучшем случае — весьма нежелательная потеря высоты, в худшем можно спугнуть врагов, а то и вообще свалиться им на голову.

На скриншоте изображён этот момент: вместо того, чтобы лететь вперёд, Бэтмен повернулся вниз головой, демонстрируя полнейшее пренебрежение к происходящему. Аналогичный баг был в предыдущей игре (Arkham Origins), и он до сих пор не исправлен. Видимо тот же самый кривой код был перенесён в новую игру. Попробуем найти, какие ошибки делают программисты в играх такого уровня, и исправить их.

В Origins баг был не таким неприятным: Бэтмен колбасился, но высоты не терял. А тут он падает вниз, что очень раздражает и мешает игре. Для начала попробуем уточнить, при каких условиях проявляется баг. Это оказывается не так просто. Пытаясь повторить глюк, можно сотни раз прыгать с крыши, и всё будет нормально. Однако, как только начинаешь играть, он появляется, причём в самые неудачные моменты. Вот как это выглядит:



После многочисленных экспериментов выяснилось, что баг проявляется, только если спрыгнуть под определённым углом, да и ещё при этом двигать мышкой. Неудивительно, что на первый взгляд ошибка кажется случайной. Когда вы прыгаете с крыши, обычно смотрите на то место, куда собираетесь лететь. Во время полёта направление корректируется мышью, и при определённом движении, причём только в самом начале (примерно через секунду после прыжка), возникает этот баг.

Почему же так происходит? Как вообще можно найти в игре место с этой ошибкой? Оказывается, можно. Для этого воспользуемся Cheat Engine, который уже не раз упоминался на хабре. Принцип примерно такой:

Найдём какую-нибудь переменную, касающуюся полёта, например, скорость. Сначала делаем снимок всей памяти в момент, когда Бэтмен летит медленно. Затем пикируем (скорость увеличивается) и ищем значения, которые увеличились. Затем опять выравниваемся (скорость уменьшается) и ищем среди ранее найденных значения, которые теперь, наоборот, уменьшились. После нескольких повторений обычно удаётся найти нужную ячейку памяти. Затем по ней (с помощью аппаратной точки останова) найдём код, который её меняет. Вот он:



Это оказалась середина длиннющей подпрограммы, почти наполовину состоящей из вычислений с плавающей точкой. Видимо это и есть код, полностью определяющий весь полёт Бэтмена. Начнём потихоньку его изучать. Как нам сообщил отладчик, скорость изменяется при выполнении команд, выделенных зеленым, значит она содержится в [rdi+0000144C], а перед этим вычисляется в регистре xmm8 (выделено красным).

Проверим это. Заменим команду subss на addss, и теперь скорость не будет падать, а только увеличиваться. Получился забавный аттракцион: можно носиться на огромной скорости по улицам, даже быстрее, чем в бэтмобиле, при этом не теряя высоты. Попадать в повороты на такой скорости становится трудновато. В нормальной игре это конечно, невозможно, то есть мы убедились, что действительно нашли код, отвечающий за полёт Бэтмена.



Теперь попробуем наоборот. Заменим обе команды на «subss xmm8,xmm8», то есть вычтем регистр сам из себя, в результате скорость должна стать равной нулю. Запустим игру. И тут мы обнаруживаем, что перед полётом имеется переходный этап, как раз та первая секунда, от момента, когда Бэтмен делает шаг в пустоту, до того, как он полностью расправит крылья. В этот момент срабатывает изменённая нами команда, и Бэтмен застывает в воздухе.

Это оказалось очень удобно. Теперь не нужно постоянно прыгать с крыши под определённым углом, при этом пытаясь дёрнуть мышкой в нужный момент, чтобы воспроизвести баг. Мы поймали тот самый момент, переход между прыжком и полётом, когда он происходит. Теперь отлично видно: пока камера смотрит выше определённого угла (примерно на линии горизонта), всё нормально, но как только она опускается ниже, вместе с ней опускается и Бэтмен, и после этого любое движение мышью вверх приводит к багу.



Настало время искать ошибку в коде. Попытки отключать куски программы наугад ничего не дали. Бэтмен застывает в нелепых позах, летит не туда, куда надо, вертится как волчок, но баг не исчезает. Остаётся кропотливо анализировать, что где хранится, и какие вычисления делаются. Начинаю просматривать всё ту же подпрограмму с начала, и вскоре находится такой фрагмент:



Не иначе как трем 16-битным значениям (в регистрах eax,edx,ecx) делают расширение знака. Это сразу наводит на мысль о трёх измерениях. А ну-ка посмотрим, что там в регистрах? Небольшие числа с разным знаком. Подвигаем мышью — значения изменяются. Видимо это реакция на манипулятор в виде векторов. Обнулим одно из значений, и убедимся, что Бэтмен теперь не реагирует на движение мыши вверх-вниз, а только в стороны. Конкретно это первое число из трёх, и оно сохраняется в памяти в ячейке [rsp+70] (выделено зелёным). Это поможет нам повторить баг в любой нужный момент.

Попробовав выполнять программу дальше по шагам, я быстро запутался в условных переходах и вычислениях. Однако, дойдя до знакомого места, где вычисляется скорость, я заметил в одном из регистров число, похожее на ранее найденные векторы. Только теперь это оказалась не мышь, а сам Бэтмен, а именно угол его наклона к горизонту, тангаж, если можно так сказать. В нормальном состоянии он имеет значения от -2548 до -15000, то есть от положения чуть ниже горизонта, до почти вертикально вниз.

Теперь воспроизводим баг. И тут оказывается, что когда Бэтмена колбасит, в этом регистре оказывается не нормальная ориентация, а какое-то произвольное число, причём даже не 16-битное, а 32-битное! Это всё объясняет. Где-то в вычислениях происходит ошибка, переполнение, или что-то вроде того, и в итоге Бэтмена мотает, как осенний лист на ветру.

Чтобы проверить это, смоделируем ситуацию. Найдём то место в памяти, где сохраняется «ошибочное» значение, и заменим его 32-битной константой. Получаем Бэтмена, застывшего в полете под фиксированным углом.



В ответ на движение мыши он поворачивает, расправляет крылья, двигает головой. Но его наклон к горизонту остаётся постоянным, причём неправильным. Бэтмен не может так летать, он не собирался так летать, к этому приводит ошибка при вычислении. К текущему углу добавляется неопределённое число, и хотя для рендеринга оно подгоняется под нужные пределы, в памяти хранится в непотребном виде. В каждом следующем цикле они достают это число, берут от него младшие 16 бит, и получают чёрт-те что. Поэтому процесс «дёргания» продолжается довольно долго, пока каким-то чудом число всё-таки не попадёт в правильный диапазон. Тогда бэтмен успокаивается и начинает лететь нормально.

И вот я снова блуждаю в коде, пытаясь определить, где в вычислениях что-то пошло не так. Из всей подпрограммы в 9 килобайт участок поиска сузился до примерно 1 килобайта, но понять здесь что-нибудь по-прежнему трудно. Через некоторое время я стал склоняться к мысли, что всё это будет слишком сложно, как вдруг заметил, что во многих SSE регистрах находится так называемое Nan (нечисло). Замечательно. Так вот в чём дело! Где-то в вычислениях получилось Nan, а стоит нечислу появиться один раз, как все операции с его участием тоже приведут к Nan, и пошло-поехало. Теперь нам достаточно пройти весь цикл по шагам, внимательно наблюдая, когда впервые появится Nan, и мы найдём то, что нам нужно:



Вот после выделенного вызова, оно и возникает. В этот момент xmm1 = 0.5, a xmm0 = -0.01. Заходим внутрь, и оказываемся в msvcr100.dll, функция powf (возведение в степень), то есть в данном случае, берется корень из отрицательного числа. Откуда же оно взялось, и почему это происходит так редко? После подробного изучения удалось выяснить, что здесь вычисляется. Рассмотрим на примере нормальную ситуацию:

В xmm0 у нас 600 — это крейсерская скорость Бэтмена. Из [rdi+0000144C] в xmm2 загружается 731 — это текущая скорость Бэтмена (заметим, это то же смещение, что в первом фрагменте кода, рассмотренном в самом начале). Затем они вычитаются (subss xmm2,xmm0), получается 131. Далее из [rdi+000013EC] берется 2200 — максимальная скорость, умножается на константу, опять вычитается xmm0 (600), получается 1270. Делим первую разницу на вторую (divss xmm2,xmm1), получаем 0,1031. Теперь это умножается на ранее вычисленный в xmm14 коэффициент (он зависит от угла, но сейчас это не важно, главное, что он всегда положительный), в итоге получаем 0,0268. Дальнейшее мы уже знаем, вычисляем из этого корень, всё хорошо.

А теперь, что получается, когда Бэтмен прыгает с крыши. Вся эта ветка выполняется, только если двинуть мышкой вверх, чтобы вычислить, на сколько можно повернуться вверх за следующий квант времени. В этот момент скорость Бэтмена оказалась равной 599. Из неё вычитают 600, получается -1, результат всей формулы отрицательный, и берут квадратный корень. Вот тут и получается Nan. Все дальнейшие вычисления совершенно очевидно идут насмарку, «нечисла» множатся, переводятся в 32-битное целое и в итоге мы получаем то, что видели.

Найдём это же место в предыдущей игре — Arkham Origins. Оказалось, там всё практически то же самое: крейсерская скорость Бэтмена тоже 600, поэтому подпрограмма нашлась почти сразу. Правда угол планирования немного ниже, вычисления идут на FPU, а корень вычисляется другим вызовом msvcr100.dll (потому что двойная точность)



Подведём итоги исследований. Полёт Бэтмена был задуман таким образом, что его скорость никогда не должна быть меньше 600. Когда он летит ровно, его скорость 600. Если он немного накреняется вниз, скорость возрастает. Когда он выравнивается, она плавно снижается до 600, но никогда не должна быть меньше. В результате программисты думали, что разница скоростей никогда не будет отрицательной, и спокойно вычисляют из этого квадратный корень.

Они сделали 3 ошибки. Не учли, что при определённых условиях вычисленная по их формуле скорость может упасть ниже 600. Потом не проверили, что берут корень из отрицательного числа. А потом вычисляли и хранили результат в 32-битной переменной, а брали от неё только 16 бит, в результате корень из отрицательного числа может браться всего один раз, а Бэтмена потом будет колбасить секунд десять.

Кстати, интересно, что при управлении геймпадом (даже на ПК) этого бага нет. Думаю, потому, что геймпад не позволяет сделать таких резких движений вверх-вниз, как мышь. Поэтому, скорее всего, на консолях эта ошибка тоже есть, просто она не проявляется.

Как исправить ошибку? Например, для Arkham Knight перед вычислением корня добавим команду «maxps xmm2,xmm9» (максимум), так как в xmm9 у нас ноль, результат всегда будет положительным. Для Origins используем команду «fabs» (модуль). Запускаем игру и убеждаемся, что глюков больше нет: Бэтмен не дёргается, а летит куда нужно.

Можно даже написать скрипты, которые найдут код по уникальной последовательности байт и исправят его. В принципе, для Origins можно прямо изменить.ехе-шник, а вот Arkham Knight защищен Denuvo, поэтому код можно менять только в памяти, когда игра уже загружена, что и делает Cheat Engine.

скрипт для Arkham Knight
<?xml version="1.0" encoding="utf-8"?>
<CheatTable CheatEngineTableVersion="18">
  <CheatEntries>
    <CheatEntry>
      <ID>0</ID>
      <Description>"Fix gliding bug"</Description>
      <LastState Activated="0"/>
      <Color>80000008</Color>
      <VariableType>Auto Assembler Script</VariableType>
      <AssemblerScript>[ENABLE]

aobscanmodule(INJECT,BatmanAK.exe,F3 41 0F 59 D6 F3 0F 10 8F 28 14 00 00 0F 28 C2 E8 D4)
alloc(newmem,$1000,INJECT)

label(code)
label(return)

newmem:

code:
  mulss xmm2,xmm14
  maxps xmm2,xmm9
  jmp return

INJECT:
  jmp code
return:
registersymbol(INJECT)

[DISABLE]

INJECT:
  db F3 41 0F 59 D6

unregistersymbol(INJECT)
dealloc(newmem)

</AssemblerScript>
    </CheatEntry>
  </CheatEntries>
  <UserdefinedSymbols/>
</CheatTable>

скрипт для Arkham Origins
<?xml version="1.0" encoding="utf-8"?>
<CheatTable CheatEngineTableVersion="18">
  <CheatEntries>
    <CheatEntry>
      <ID>0</ID>
      <Description>"Fix gliding bug (Origins)"</Description>
      <LastState Activated="1"/>
      <Color>80000008</Color>
      <VariableType>Auto Assembler Script</VariableType>
      <AssemblerScript>[ENABLE]

aobscanmodule(INJECT,BatmanOrigins.exe,D9 86 E8 0B 00 00 E8 3E)
alloc(newmem,$1000)

label(code)
label(return)

newmem:

code:
  fabs
  fld dword ptr [esi+00000BE8]
  jmp return

INJECT:
  jmp code
  nop
return:
registersymbol(INJECT)

[DISABLE]

INJECT:
  db D9 86 E8 0B 00 00

unregistersymbol(INJECT)
dealloc(newmem)


</AssemblerScript>
    </CheatEntry>
  </CheatEntries>
  <UserdefinedSymbols>
    <SymbolEntry>
      <Name>INJECT</Name>
      <Address> 00E3AB33</Address>
    </SymbolEntry>
  </UserdefinedSymbols>
</CheatTable>

Те, кто не хочет лезть в код игры, могут просто не трогать мышку в начале прыжка, или по крайней мере не двигать ей вверх. Ну и надеяться, что когда-нибудь разработчики это исправят. Кстати, пока я готовил пост, они выпустили очередной патч, после которого, похоже, всё стало ещё хуже. У многих игра стала вылетать, и пока не была возвращена в продажу. Впрочем, теперь я этому не удивлён.

+224
85k 278
Comments 101
Top of the day