Привет, хабр! В своей предыдущей статье я рассказал об интересном баге в одной старенькой игрушке, наглядно продемонстрировал явление накопления ошибки округления и просто поделился своим опытом в обратной разработке. Я надеялся, что на этом можно было бы поставить точку, но я очень сильно ошибался. Поэтому под катом я расскажу продолжение истории о звере по имени Timebug, о 60 кадрах в секунду и об очень интересных решениях при разработке игр.
Во время написания предыдущей части этой эпопеи вокруг неправильно посчитанных времен трасс я непроизвольно попытался затронуть как можно больше игр. Делать дополнительную работу было лень, поэтому я искал симптомы во всех играх серии NFS, что у меня были на тот момент. Под раздачу попал и Underground 2, но изначальных симптомов я там не нашел:
Как видно из картинки (она кликабельна, кстати), IGT подсчитывается другим способом, который явно завязан на int, а потому накопления ошибки не должно было быть. Я радостно заявил об этом в нашем коммьюнити и планировал уже забыть об этом, но нет: Ewil вручную пересчитал некоторые видео и снова обнаружил разницу во времени. Мы решили, что пока у нас нет времени разбираться с этим и я сконцентрировался на уже известной тогда проблеме, но вот сейчас я смог выделить себе время и заняться именно этой игрой.
Я изучил поведение глобального таймера и обнаружил, что его «сбрасывают» с каждым рестартом гонки, с каждым выходом в меню и вообще в любой удобный момент. Это не входило в мои планы, потому что связь с уже известной проблемой потерялась окончательно, и этот таймер просто не должен был ломаться. От отчаяния я записал прохождение 10 кругов и руками посчитал время. К моему удивлению, времена круга там были на 100% точны.
Единственное, что оставалось – дизассемблировать и смотреть, что же там не так. Не вдаваясь в подробности покажу псевдокод процедуры, которая считает это несчастное IGT:
Ну, во-первых:
Изначально этот код показался мне совершенно бессмысленным. Зачем это умножать на 4000, а потом еще добавлять половинку?
На самом деле, это очень хитрая магия. 4000 всего лишь константа, которая пришла кому-то из разработчиков в голову… А вот +0.5 это такой интересный способ округления по законам математики. Добавьте половинку к 4.7 и при обрубании до int получите 5, а при добавлении и округлении 4.3 получим 4, как и хотели. Способ не самый точный, но, наверное, работает быстрее. Лично я возьму на заметку.
А теперь, дорогие читатели, я хочу поиграть с вами в игру. Посмотрите на полный псевдокод выше и попробуйте найти там ошибку. Если устанете или вам просто не интересно, переходите к следующей части.
Ошибка под спойлером, чтобы случайно не подсмотреть. Немного поясню: 0.01(6) это 1/60 секунды. Весь код выше, судя по всему, это попытка подсчета и компенсирования подлагивания движка, но они не учли то, что не все играют в 60fps. Отсюда и получился тот самый интересный результат, когда в моем видео все круги совпали с действительностью, а у Ewil’а нет. Он играет с выключенной вертикальной синхронизацией, а игра заблокирована на максимум 120 fps и, соответственно, для его компьютера код отрабатывал неправильно. Я слегка доработал код выше и привел его в человеческий вид:
Здесь видно, что при подсчете отставания изначально используется действительное время кадра, а в дальнейшем используются захардкоженные 60 фпс. SUSPICIOUS!
Выключаю vsync и получаю 120 кадров в секунду. Иду записывать видео и получаю примерно 0.3 секунды разницы на круге. Бинго!
Дело осталось за малым, пропатчить хардкорные 60фпс на хардкорные 120фпс. Для этого смотрим ассемблерный код и находим адрес, по которому находится эта магическая константа: 0х007875BC.
На этот раз я не стал писать никаких особых программ, а просто руками в Cheat Engine изменил значение этой переменной на необходимое. После этого я еще раз записал 10 кругов и посчитал время – IGT и RTA наконец совпадали.
На самом деле, они совпадали не на 100%. Но в большинстве своем из-за того, что запись видео очень сильно просаживала мне частоту кадров, из-за чего игра переставала адекватно рассчитывать разницу времен. Но в целом разница была в районе 0.02 секунды.
Я еще немного поискал в коде, на что влияют те переменные, что так усердно высчитывались в процедуре подсчета времени. Нашел я не очень много, но g_fDiffTime используется где-то в движке рядом с g_fFrameTime. Скорее всего, мое предположение о компенсации подлагивания оказалось верным. Но кто этих разработчиков знает то.
Я даже не знаю, в который раз я натыкаюсь на игру, которая предполагает 60 кадров в секунду. Это очень плохой стиль написания игр, и я настоятельно рекомендую вам, читатели, учитывать разницу в железе. Особенно если вы инди-разработчик. И совсем особенно если вы разрабатываете на ПК. Для консолей добиться различных мощностей железа не получится, а на ПК из-за этого постоянно всплывают проблемы. А еще есть мониторы с 120/144Hz частотой обновления, и даже больше. Да и g-sync уже подъехал.
Но NFS – это порты с консолей, так что в решениях часто наблюдается чисто консольный подход: предположить, что ФПС не поднимется выше 60 (30, 25, any number), и многие решения наглухо затачиваются именно под это число кадров в секунду. Увы, это стало сильнее проявляться в новых частях серии.
На этот раз статья получилась не такой уж объемной, хотя простора для исследований тут много. Надеюсь, найдется еще что-то интересное в этих играх, о чем можно будет рассказать.
Предыстория
Во время написания предыдущей части этой эпопеи вокруг неправильно посчитанных времен трасс я непроизвольно попытался затронуть как можно больше игр. Делать дополнительную работу было лень, поэтому я искал симптомы во всех играх серии NFS, что у меня были на тот момент. Под раздачу попал и Underground 2, но изначальных симптомов я там не нашел:
Как видно из картинки (она кликабельна, кстати), IGT подсчитывается другим способом, который явно завязан на int, а потому накопления ошибки не должно было быть. Я радостно заявил об этом в нашем коммьюнити и планировал уже забыть об этом, но нет: Ewil вручную пересчитал некоторые видео и снова обнаружил разницу во времени. Мы решили, что пока у нас нет времени разбираться с этим и я сконцентрировался на уже известной тогда проблеме, но вот сейчас я смог выделить себе время и заняться именно этой игрой.
Симпотмы
Я изучил поведение глобального таймера и обнаружил, что его «сбрасывают» с каждым рестартом гонки, с каждым выходом в меню и вообще в любой удобный момент. Это не входило в мои планы, потому что связь с уже известной проблемой потерялась окончательно, и этот таймер просто не должен был ломаться. От отчаяния я записал прохождение 10 кругов и руками посчитал время. К моему удивлению, времена круга там были на 100% точны.
Интересные факты
На самом деле, был обнаружен и другой таймер, который также считал время во float, но он оказался немного бесполезным. Изменяя его я не добился никаких видимых результатов.
А этот int таймер почему-то сбрасывается не в 0, а устанавливается в 4000.
А этот 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), и многие решения наглухо затачиваются именно под это число кадров в секунду. Увы, это стало сильнее проявляться в новых частях серии.
На этот раз статья получилась не такой уж объемной, хотя простора для исследований тут много. Надеюсь, найдется еще что-то интересное в этих играх, о чем можно будет рассказать.
Комментарии (6)
Keyten
11.08.2017 23:38+2А вот +0.5 это такой интересный способ округления по законам математики.
Это очень давно известная штука.
floor(число + 0.5) — правильно округляет число (дробная часть меньше 0.5 — вниз, выше — вверх),
где floor отбрасывает дробную часть.
zanac
12.08.2017 21:26А вы не сталкивались со случаями, когда игра неверно определяла доступный объём видеопамяти?
В Omikron: Nomad soul на картах в видеопамятью >= гигабайта игра ругается, что мало памяти.GrimMaple Автор
12.08.2017 21:28Встречался с похожей проблемой: Juiced отказывается работать, если на компьютере установлено больше 2х гигабайтов оперативной памяти. С двумя гигабайтами еще понятно — в int не влезли, а вот с 1 гигабайтом видеопамяти интересно получается.
zanac
13.08.2017 09:57Я не спец в дизассемблере — посмотрите в чем дело? Запускал под Ollydebug, перед завершением работы в регистрах хранящих числа с плавающей точкой было значение 900 с чем-то тысяч…
gag_fenix
В мультиплеерных ММО-гоночках «The Crew» (2014) при просадке FPS вы начинаете ехать медленнее — видно невооруженным глазом.