Pull to refresh

Timebug часть 2: интересные решения от EA Black Box

Reading time 4 min
Views 9.4K
Привет, хабр! В своей предыдущей статье я рассказал об интересном баге в одной старенькой игрушке, наглядно продемонстрировал явление накопления ошибки округления и просто поделился своим опытом в обратной разработке. Я надеялся, что на этом можно было бы поставить точку, но я очень сильно ошибался. Поэтому под катом я расскажу продолжение истории о звере по имени Timebug, о 60 кадрах в секунду и об очень интересных решениях при разработке игр.



Предыстория


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



Как видно из картинки (она кликабельна, кстати), IGT подсчитывается другим способом, который явно завязан на int, а потому накопления ошибки не должно было быть. Я радостно заявил об этом в нашем коммьюнити и планировал уже забыть об этом, но нет: Ewil вручную пересчитал некоторые видео и снова обнаружил разницу во времени. Мы решили, что пока у нас нет времени разбираться с этим и я сконцентрировался на уже известной тогда проблеме, но вот сейчас я смог выделить себе время и заняться именно этой игрой.

Симпотмы


Я изучил поведение глобального таймера и обнаружил, что его «сбрасывают» с каждым рестартом гонки, с каждым выходом в меню и вообще в любой удобный момент. Это не входило в мои планы, потому что связь с уже известной проблемой потерялась окончательно, и этот таймер просто не должен был ломаться. От отчаяния я записал прохождение 10 кругов и руками посчитал время. К моему удивлению, времена круга там были на 100% точны.

Интересные факты
На самом деле, был обнаружен и другой таймер, который также считал время во float, но он оказался немного бесполезным. Изменяя его я не добился никаких видимых результатов.
А этот int таймер почему-то сбрасывается не в 0, а устанавливается в 4000.


Единственное, что оставалось – дизассемблировать и смотреть, что же там не так. Не вдаваясь в подробности покажу псевдокод процедуры, которая считает это несчастное IGT:

if ( g_fFrameLength != 0.0 )
{
    float v0 = g_fFrameDiff + g_fFrameLength;
    int v1 = FltToDword(v0);
    g_dwUnknown0 += v1;
    g_dwUnknown1 = v1;
    g_dwUnknown2 = g_dwUnknown0;
    g_fFrameDiff = v0 - v1 * 0.016666668;
    g_dwIGT += FltToDword(g_fFrameLength * 4000.0 + 0.5);
    LODWORD(g_fFrameLength) = 0;
    ++g_dwFrameCount;
    g_fIGT = (double)g_dwIGT * 0.00025000001;   // Divides IGT by 4000 to get time in seconds
}

Ну, во-первых:

g_dwIGT += FltToDword(g_fFrameLength * 4000.0 + 0.5);



Изначально этот код показался мне совершенно бессмысленным. Зачем это умножать на 4000, а потом еще добавлять половинку?

На самом деле, это очень хитрая магия. 4000 всего лишь константа, которая пришла кому-то из разработчиков в голову… А вот +0.5 это такой интересный способ округления по законам математики. Добавьте половинку к 4.7 и при обрубании до int получите 5, а при добавлении и округлении 4.3 получим 4, как и хотели. Способ не самый точный, но, наверное, работает быстрее. Лично я возьму на заметку.

А теперь, дорогие читатели, я хочу поиграть с вами в игру. Посмотрите на полный псевдокод выше и попробуйте найти там ошибку. Если устанете или вам просто не интересно, переходите к следующей части.

Ошибка


Строчка
g_fFrameDiff = v0 — (double)v1 * 0.016666668;

Ошибка под спойлером, чтобы случайно не подсмотреть. Немного поясню: 0.01(6) это 1/60 секунды. Весь код выше, судя по всему, это попытка подсчета и компенсирования подлагивания движка, но они не учли то, что не все играют в 60fps. Отсюда и получился тот самый интересный результат, когда в моем видео все круги совпали с действительностью, а у Ewil’а нет. Он играет с выключенной вертикальной синхронизацией, а игра заблокирована на максимум 120 fps и, соответственно, для его компьютера код отрабатывал неправильно. Я слегка доработал код выше и привел его в человеческий вид:

if ( g_fFrameLength != 0.0 )
{
    float tmpDiff = g_fFrameDiff + g_fFrameLength;
    int diffTime = FltToDword(v0);
    g_dwUnknown0 += diffTime;  // Some unknown vars
    g_dwUnknown1 = diffTime;
    g_dwUnknown2 = g_dwUnknown0;
    g_fFrameDiff = tmpDiff - diffTime * 1.0/60;
    g_dwIGT += FltToDword(g_fFrameLength * 4000 + 0.5);
    g_fFrameLength = 0;
    ++g_dwFrameCount;
    g_fIGT = (float)g_dwIGT / 4000;   // Divides IGT by 4000 to get time in seconds
}

Здесь видно, что при подсчете отставания изначально используется действительное время кадра, а в дальнейшем используются захардкоженные 60 фпс. SUSPICIOUS!
Выключаю vsync и получаю 120 кадров в секунду. Иду записывать видео и получаю примерно 0.3 секунды разницы на круге. Бинго!

Дело осталось за малым, пропатчить хардкорные 60фпс на хардкорные 120фпс. Для этого смотрим ассемблерный код и находим адрес, по которому находится эта магическая константа: 0х007875BC.

Спойлер
На самом деле, известно, что эта константа типа float/double и будет загружаться на ФПУ. ФПУ не умеет загружать из регистра, так что ей суждено было оказаться где-то в памяти. Хорошо, что она оказалась не на стеке, иначе я так просто не отделался бы. Пришлось бы вносить изменения в непосредственно код игры, чего я не хотел делать.

На этот раз я не стал писать никаких особых программ, а просто руками в Cheat Engine изменил значение этой переменной на необходимое. После этого я еще раз записал 10 кругов и посчитал время – IGT и RTA наконец совпадали.

На самом деле, они совпадали не на 100%. Но в большинстве своем из-за того, что запись видео очень сильно просаживала мне частоту кадров, из-за чего игра переставала адекватно рассчитывать разницу времен. Но в целом разница была в районе 0.02 секунды.

Я еще немного поискал в коде, на что влияют те переменные, что так усердно высчитывались в процедуре подсчета времени. Нашел я не очень много, но g_fDiffTime используется где-то в движке рядом с g_fFrameTime. Скорее всего, мое предположение о компенсации подлагивания оказалось верным. Но кто этих разработчиков знает то.

Послесловие


Я даже не знаю, в который раз я натыкаюсь на игру, которая предполагает 60 кадров в секунду. Это очень плохой стиль написания игр, и я настоятельно рекомендую вам, читатели, учитывать разницу в железе. Особенно если вы инди-разработчик. И совсем особенно если вы разрабатываете на ПК. Для консолей добиться различных мощностей железа не получится, а на ПК из-за этого постоянно всплывают проблемы. А еще есть мониторы с 120/144Hz частотой обновления, и даже больше. Да и g-sync уже подъехал.

Но NFS – это порты с консолей, так что в решениях часто наблюдается чисто консольный подход: предположить, что ФПС не поднимется выше 60 (30, 25, any number), и многие решения наглухо затачиваются именно под это число кадров в секунду. Увы, это стало сильнее проявляться в новых частях серии.

На этот раз статья получилась не такой уж объемной, хотя простора для исследований тут много. Надеюсь, найдется еще что-то интересное в этих играх, о чем можно будет рассказать.
Tags:
Hubs:
+24
Comments 6
Comments Comments 6

Articles