• Главная
  • Карта сайта
Не найдено

Як EA ускладнили нам життя, або як ми чинили баг 12-річної давності

  1. лірика
  2. Як це працює
  3. Float - пекло перфекціоніста.
  4. Усуваємо небажану поведінку
  5. Summary

Іноді в програми закрадаються баги. Причому закрадаються так, що виявити їх виходить лише через багато-багато років після випуску, коли лагодити їх вже нерентабельно. Іноді такі баги виявляються занадто критичними, щоб їх ігнорувати. Тому під катом я розповім, як ми усували один такий критичний баг в одній старенькій гонялки. А заодно наочно продемонструю, чим поганий float, які можуть бути наслідки і як з цим боротися.
Іноді в програми закрадаються баги

лірика


Йтиметься про гру Need for Speed: Most Wanted. Ця гра дуже популярна і вкрай улюблена багатьма геймерами-ентузіастами, і багатьма вважається чи не найкращою в серії. Навіть якщо ви не азартний гравець, то напевно чули про цю гру зокрема або про серію в цілому. Але про все по порядку.
Я - спідранер. Проходжу гри на швидкість. Довелось мені бути одним з першопрохідців у справі швидкісного проходження гоночних ігор, тому я заодно є одним з «глобальних модераторів» спідран-ком'юніті серії NFS. Участь зі мною розділив чех під ніком Ewil.
Чому доля? Тому що в один прекрасний момент до нас в ДІСКОРДІЯ-сервер прийшла людина і заявив, що всі наші рекорди трас неправильні, і що ми - нубі. Згнітивши серце, пригнічуючи багет від, як здавалося, необгрунтованого обвинувачення і борючись з мовним бар'єром (англійською ця людина володіє на дуже поганому рівні), ми почали розбиратися, що ж не так з нашими рекордами. З обривків мови ми зрозуміли, що в грі є якийсь «timebug», який робить IGT неправильним. Ewil переглянув деякі записи і руками перерахував час. Виявилося, нам не брехали. На записах IGT різко відрізнялося від RTA. Це були не пара мілісекунд, які теж можу вирішити результат рекорду, а місцями різниця доходила навіть до кількох секунд (!).
Ми почали шукати причину і наслідки цього явища. Ще задовго до цього я в особистих інтересах намагався «витягнути» з гри IGT. Моя спроба не увінчалася успіхом, і я якось забив на цю ідею. Інформації в інтернеті ми знайти не змогли, за винятком якоїсь англійської сторінки з дуже коротким описом, без будь-якої доказової бази. Пошук по Ютубі також не приніс результатів, але були знайдені записи, які свідчили «No TimeBug».
Трохи пізніше я познайомився зі SpeedyHeart, і вона мені підказала, що в грі час вважається як float. Тут все почало прояснюватися, і ми повільно переходимо від похмурого вступу до лютому екшону!

Як це працює


Озброївшись Cheat Engine, OllyDbg, DxWND і NFS: MostWanted версії 1.3, я поліз ритися в пам'яті. Викопав я приблизно ось що (картинка клікабельні):
Озброївшись Cheat Engine, OllyDbg, DxWND і NFS: MostWanted версії 1
Нас цікавлять останні три адреси. Вони зберігають у собі IGT для різних ситуацій. Чому вони float - одному Блек Боксу відомо ... Але так не роблять! Float, у нього ж точність, як у дробовика, а може і того гірше.
занудство

До речі, в попередній грі серії час вважається як int - загальна кількість тиків процесора (а коли воно перевалить за ті самі 2 мільярди, гра запанікує і вважатиме за краще впасти). Відповідно, цей модуль їх движка був переписаний, але краще не стало. У попередній частині, до речі, IGT теж відрізняється від RTA, але там це, швидше за все, викликано подлагіваніем движка.


Власне, трохи про самих таймерах. Таймери зберігають час в секундах, т. Е. Ціла частина - кількість повних секунд. Два з цих таймерів, а саме Global IGT і Race IGT, періодично обнуляються. Для Global IGT це відбувається в момент виходу в головне меню, а Race IGT обнуляється при рестарт гонки. Підрахунок IGT проводиться через Global IGT, і в якийсь момент часу йому вже не вистачає точності. Через це час вважається неправильно.
На цій стадії мене зацікавили кілька питань:

  1. Раз вже є різниця в часі, то чи відрізняється геймплей з багом і без? Логічно припустити, що якщо IGT прискорюється, то і в цілому гра повинна ставати «швидше»
  2. Які рамки у цього бага? Як він буде себе вести при різних значеннях таймера, і як на це реагуватиме гра.

Відповідь на питання номер 1 був знайдений вкрай швидко. Я просто взяв і змінив свідчення Global IGT на 300000.0 і отримав те, що отримав. Час прискорилося майже в два рази (!), Проте на фізиці і поведінці гри це ніяк не позначилося. Приколу заради я Тиркало і інші таймери, але вони, чомусь, ні на що не впливають. Власне, якби з прискоренням часу прискорювався і геймплей, то в нашому світі спідранерства це вважається цілком законним. Все таки ми любимо баги. Але такий розклад нікого не влаштував.
Я пішов трохи глибше і знайшов відповідь на питання 2. Як тільки Global IGT досягає позначки в 524 288 час в грі повністю зупиняється. Це трохи бентежить гру, і вона починає погано себе вести робити цікаві речі. Наприклад, не дає почати гонку після рестарту, намертво блокуючи гру (вийти з неї можна тільки через диспетчер задач або Alt + F4). Відрив / відставання від суперників перестає працювати. А якщо програти гонку, то гра відправляє вас у вільне плавання по світу.

Float - пекло перфекціоніста.


Хоч і не в прямому сенсі, але все ж. Для наочної демонстрації я написав невелику програму, яка допоможе мені оцінити всю сумну відбувається.
Перед безпосередньо кодом, розпишу трохи про цілі. Відомо, що гра «блокує» цикл поновлення фізики на 120 раз в секунду (знову ж, спасибі SpeedyHeart за інформацію). Однак vsync обрубує оновлення фізики ще сильніше, до 60 разів на секунду. Відповідно, ми просто візьмемо float-змінну і будемо циклічно туди додавати 1/60 секунди. Потім ми порахуємо, за скільки кроків ми добилися результату, і за скільки кроків ми повинні були досягти цього результату. Також будемо робити все циклічно для різних випадкових величин і вважати середню помилку в розрахунках. Будь-які відхилення в 2 і менше кроків (33мс) ми будемо вважати незначними, тому що гра показує час до сотих секунди.
#include <stdio.h> #include <stdlib.h> #include <time.h> #include <math.h> #include <conio.h> #define REPEATS тисячу // Кількість перевірок #define ESCAPE 27 // Код клавіші ESCAPE #define TEST_TIME 2 // Час, проведений на трасі #define START_TIME 0 // Початкове значення внутріігрового тайміера void main () {time_t t; srand (time (& t)); while (true) {float diffs [REPEATS]; int frame_diffs [REPEATS]; // Сторейдж різниць for (int i = 0; i <REPEATS; i ++) {int limit = rand ()% TEST_TIME + 1; // Генеруємо випадкове кількість часу, // +1 не дасть нам згенерувати 0 limit * = 60; // Хвилини в секунди limit + = (START_TIME * 60); // Вирівнюємо кінцеве час float t = 0.0f + (START_TIME * 60); // Вирівнюємо потрібний проміжок часу float step = 1.0f / 60.0f; // І лочім все на 60 фпс int steps = 0; while (t <limit) {steps ++; t + = step; } // Вважаємо очікування і виводимо їх на екран double expectation = (double) (limit - START_TIME * 60) / (1.0 / 60.0); printf ( "% f \ n", t); printf ( "Difference =% f; steps =% d \ n", t - limit, steps); printf ( "Expected steps =% f; frames dropped =% d \ n", expectation, (int) expectation - (int) steps); diffs [i] = fabs (t - limit); frame_diffs [i] = (int) expectation - (int) steps; } // Вважаємо середнє і статистику float sum = 0; int frame_sum = 0; for (int j = 0; j <REPEATS; j ++) {sum + = diffs [j]; frame_sum + = frame_diffs [j]; } Printf ( "Avg. Time difference =% f, avg. Frame difference =% d \ n", sum / REPEATS, frame_sum / REPEATS); // У разі "any key" продовжуємо, в разі "ESCAPE" виходимо printf ( "Press any key to continue, press esc to quit \ n"); if (getch () == ESCAPE) break; }}
Змінюючи значення START_TIME і TEST_TIME ми можемо отримати необхідну нам статистику. В цілому, поки START_TIME не перевищує 15 хвилин, то звичайний 2-х хвилинний заїзд виявиться «вільним» від бага. Різниця залишається критичною в рамках гри, 1-2 кадру:
Хоч і не в прямому сенсі, але все ж
16 Хвилин ж виявилися «критичною точкою», коли час нещадно пливе:

Цікавий так само той момент, що в залежності від START_TIME буде змінюватися «сторона» помилки. Наприклад, після півтора годинного безперервного геймплея час почне текти повільніше, ніж повинно:

Пограти зі значеннями ще трохи я оцінив, що в вийшла програмі «час» тече приблизно так само, як в грі. Це було підтверджено практично - будь-які рекорди, записані «з головного меню» протягом перших 15 хвилин геймплея були чистими. При START_TIME близькому до 300000 секунд кількість кроків було майже в два рази менше, ніж очікувалося. При START_TIME, більшому магічної константи 524288 програма переставала працювати. Все це підтверджувало, що процес підрахунку часу був скопійований вірно.
ще нудності

Результати обчислень будуть відрізнятися, якщо гра відпрацьовує більше ніж 60 кадрів в секунду. При 120 кадрах в секунду значення «вільної від бага зони» становить 7 хвилин. Та й час зупиняється значно раніше. У двох словах, чим швидше працює гра, тим сильніше стає помилка.


Усуваємо небажану поведінку


Тепер, коли відома проблема і її поведінка, можна її усунути. Потрібно лише перезаписувати Global IGT щоразу, коли гравець починає заїзд заново. Це можна дізнатися досить просто - в цей момент обнуляється Race IGT. Але тут є проблема.
Є два видання гри: NFS: Most Wanted і NFS: Most Wanted Black Edition. Друге видання включає в себе дві додаткові машини і дві траси, та 69-е випробування. Але, технічно, це дві абсолютно різні ігри! Їх запускаються файли відрізняються. Крім цього, є патч 1.3 ... Який відрізняється для кожного видання. В результаті у нас є 4 різних версії гри, які треба підтримувати. Цей факт робить «правильний» шлях надмірно складним і невиправданим. По-хорошому, потрібно злегка підправити файл, що запускається і обнуляти лічильник там, але ... Правити 4 різних файлів, які ще й запаковані, та захищені від налагодження ... Краще просто напишемо просту програму, яка буде в реалтайм відстежувати стан таймерів і обнуляти їх при необхідності. Писати будемо на C #.
Тепер, коли відома проблема і її поведінка, можна її усунути
Ось таку архітектурку я накидав. GameProcess - це допоміжний клас, який спрощує доступ до читання-запису пам'яті процесу. GameHolder - серце програми. Він буде форматувати GameProcess, а при «підчепити» процесу визначатиме версію гри і створювати необхідний екземпляр спадкоємця Game. Оскільки логіка «фікса» не відрізняється від версії до версії, то її краще винести в один клас.
Як же нам визначити версію? Просто - за розміром основного модуля. Я спеціально реалізував проперти ImageSize. А щоб не захаращувати код магічними константами, запив enum:
enum ProcessSizes {MW13 = 0x00678e4e}
Решта версій додамо в міру їх потрапляння до мене в руки.
isUnknown відповідає за той факт, чи вдалося нам визначити версію чи ні. З усього класу нам цікавий тільки метод Refresh, ось він:
public void Refresh () {if (! process.IsOpen) {// In cases when the process is not open, but the game exists // The process had either crashed, either was exited on purpose if (game! = null) Console .WriteLine ( "Process lost (quit?)"); game = null; return; } If (isUnknown) // If we could not determine game version, do nothing {return; } // If process is opened, but the game does not exist, we need to create it if (process.IsOpen && game == null) {Console.WriteLine ( "Opened process, size = 0x {0: X}" , process.ImageSize); switch ((ProcessSizes) process.ImageSize) // Guessing version {case ProcessSizes.MW13: game = new MW.MW13 (process); break; default: Console.WriteLine ( "Unknown game type"); isUnknown = false; break; }} // At last, update game game.Update (); }
Логіка фікса вийшла зовсім простенької:
public abstract class Game {private float lastTime; private GameProcess game; /// <summary> /// Synch-timer's address /// </ summary> protected int raceIgtAddress; /// <summary> /// Timer-to-sync address /// </ summary> protected int globalIgtAddress; private void ResetTime () {byte [] data = {0, 0, 0, 0}; game.WriteMemory (globalIgtAddress, data); } Public void Update () {float tmp = game.ReadFloat (raceIgtAddress); if (tmp <lastTime) {ResetTime (); Console.WriteLine ( "Timer reset"); } LastTime = tmp; } Public Game (GameProcess proc) {game = proc; lastTime = -2; // Why not anyway}}
Справа залишилася за малим: реалізувати версію, виставивши в конструкторі необхідно значення відповідним protected-змінним. В мейн ж просто кидаємо цикл поновлення в окремий тред і забуваємо про нього. Ах да, через особливості карток Nvidia і особливостей реалізації установника ігор NFS ми буде приймати на вхід ім'я процесу, щоб була можливість кастомізації.
class Program {static void Run (object proc) {GameHolder holder = new GameHolder ((string) proc); while (true) {Thread.Sleep (100); holder.Refresh (); }} Static void Main (string [] args) {Thread t = new Thread (new ParameterizedThreadStart (Run)); t.Start (args [0]); Console.WriteLine ( "Press any key at any time to close"); Console.ReadKey (); t.Abort (); }}
На цьому фікс закінчується. Компілюємо, запускаємо і забуваємо про таймбаге, yay! ^ _ ^ Картинка клікабельні.

Насправді, нікуди цей баг не дінеться. Якщо один заїзд фізично не вкладеться в рамки 15 хвилин, то тут вже нічого не поробиш. Але таких заїздів в грі аж один, і той від поліції.
Повні вихідні на гітхабе .

Summary


Ось так один маленький баг нехило зіпсував нам життя. А адже його можна було уникнути, якби Black Box використовували свого часу double, але немає. До речі, це яскравий приклад того, як «написане одного разу» виливається в купу невловлюваними / перекочується багів. Timebug присутній в кожній грі від Black Box ever since. У Carbon, ProStreet і навіть Undercover. В останньому вони поміняли логіку підрахунку IGT, але ці три таймера там все так само присутні, і помилки округлення призводять до дивних наслідків. SpeedyHeart обіцяла зробити відео-огляд всієї знайденої в процесі інформації, так що чекаємо-с.
Чому мене навчила ця ситуація? Не знаю. Я і так розумів, що використовувати float для серйозних обчислень - ідея сумнівна. Але тепер я краще уявляю, як саме все це буде працювати на практиці. Однак забавно вийшло, що така серйозна компанія могла допустити таку серйозну помилку, та ще й кілька років поспіль не помічати її.
Мені здається, що для даної задачі (підрахунок IGT) потрібно використовувати такий шлях: ставити timestamp на початку заїзду, а потім вираховувати з поточного часу. Причому арифметичних операцій варто уникати, навіть над цілими числами. 1/60 секунди це 16, (6) мілісекунд, тому в разі цілого числа ми будемо наївно відкидати 0, (6) при кожному додаванні, що призведе до неточностей у підрахунку.
В якомусь осяжному майбутньому я постараюся написати фікс і на інші версії. На цьому у мене все, спасибі за увагу.
UPD: Виправив посилання на гітхаб у зв'язку з переїздом на нове ім'я.
UPD2: викотився другу частину, зацікавлені можуть прочитати .

Чому доля?
Як же нам визначити версію?
Quit?
Чому мене навчила ця ситуація?
Провайдеры:
  • 08.09.2015

    Batyevka.NET предоставляет услуги доступа к сети Интернет на территории Соломенского района г. Киева.Наша миссия —... 
    Читать полностью

  • 08.09.2015
    IPNET

    Компания IPNET — это крупнейший оператор и технологический лидер на рынке телекоммуникаций Киева. Мы предоставляем... 
    Читать полностью

  • 08.09.2015
    Boryspil.Net

    Интернет-провайдер «Boryspil.net» начал свою работу в 2008 году и на данный момент является одним из крупнейших поставщиков... 
    Читать полностью

  • 08.09.2015
    4OKNET

    Наша компания работает в сфере телекоммуникационных услуг, а именно — предоставлении доступа в сеть интернет.Уже... 
    Читать полностью

  • 08.09.2015
    Телегруп

    ДП «Телегруп-Украина» – IT-компания с 15-летним опытом работы на рынке телекоммуникационных услуг, а также официальный... 
    Читать полностью

  • 08.09.2015
    Софтлинк

    Высокая скоростьМы являемся участником Украинского центра обмена трафиком (UA — IX) с включением 10 Гбит / сек... 
    Читать полностью