Играта GTA Online е печално известна със своята твърде бавна скорост на зареждане. Наскоро отново я стартирах, за да извърша нови мисии, и бях шокиран от това, че тя се зарежда също толкова бавно, както бе преди 7 години!
Това повече не можеше да се търпи и реших да разбера какви са причините за тази изключително бавна скорост на зареждане на играта.
Анализът
Както обикновено, в началото реших да проверя дали и някой друг не се е сблъсквал с този проблем. Повечето от намерените резултати приличаха повече на анекдоти относно това, колко сложна трябва да е една компютърна игра, за да се зарежда толкова дълго. Намерих описани различни методи, при които с помощта на два мода, зареждането на GTA Online става с около 30 секунди по-бързо, но трябва да се има предвид, че вторият мод дава възможност за пропускане на началното видео с логотипа на компанията R*, което не е кой знае какво постижение...
В същото време моят персонален компютър...
Бенчмаркът
- Време за зареждане на основния сюжетен режим: около една минута и 10 секунди
- Време за зареждане на онлайн режима: около шест минути
- Началото меню за стартиране на играта изключено, а времето от появата на логотипа R* до началото на геймплея няма да отчитам
- Централният процесор е малко старият, но все още подходящ AMD FX-8350
- Имам евтин SSD: KINGSTON SA400S37120G
- Оперативната памет е съставена от два RAM модула Kingston 8192 MB (DDR3-1337) 99U5471
- Графичната карта е сравнително добра: NVIDIA GeForce GTX 1070
Разбирам, че моят компютър е доста остарял, но защо дявол да го вземе онлайн режимът се зарежда 6 пъти по-бавно?
Не съм само аз
Според направеното от Reddit запитване, проблемът е толкова широко разпространен, че вбесява повече от 80% геймърите, които харесват тази игра. Да напомним на момчетата от R*, че вече минаха седем години, а нещата не се оправят и дори стават по-зле.
Може да се каже, че само 18,8% от играчите имат наистина мощни машини, при 81,2% положението с хардуера е твърде тъжно, а при 35,1% - съвсем печално
Разрових се за повече подробности за тези 20% късметлии, зареждането на играта при които отнема по-малко от 3 минути и открих няколко бенчмарка, които показват, че времето за зареждане на онлайн режима при тях е около 2 минути. За да получа време на зареждане на тази игра от само две минути бих убил хакнал всичко! По всичко личи, че времето за зареждане явно зависи от хардуера, но числата нещо не съвпадат...
Но как така се получава, че при хората, които са правили тези бенчмаркове, зареждането на служебния режим така или иначе заема около една минута, а при мен над 5 минути. Много добре разбирам, че тяхната техника е много по-добра от моята, само че не и пет пъти.
Измервания с висока точност
Реших да използвам мощното средство Task Manager, за да направя подробен анализ и разследване, и по този начин да изясня, кои точно ресурси са тясното място в тази ситуация.
В продължение на една минута се зареждат стандартните ресурси на сюжета, след което играта над четири, че и повече минути силно натоварва процесора
След една минута зареждане на общите ресурси на тази игра, GTA започва да натоварва до максимум едното ядро на моя процесор и в продължение на 4 и повече минути нищо друго не прави.
Може би осъществява достъп до диска? Няма такова нещо! Може би използва мрежата? Малко, но след няколко секунди трафикът пада почти до нулата. Може би се използва графичния процесор? Не, и неговият ресурс е на практика на нулата. Използване на оперативната памет? И това не е - графиката е съвсем плоска...
Какво тогава става? Да не би тази игра да осъществява добив на криптовалута? Започва да ми се струва, че проблемът е в кода. При това много лош код.
Само една нишка
Въпреки че моят централен процесор има 8 ядра и все още се представя добре, той е създаден в едни по-стари времена. Тогава еднонишковата производителност на процесорите на AMD бе много по-слаба от тази на процесорите на Intel. Може и това да е причината, но по този начин не може да се обясни тази огромна разлика във времето на зареждане. Все пак някои неща стават ясни.
Странно е, че тази игра използва само централния процесор. Очаквах повечето данни да се зареждат от диска, както и да има немалко мрежови запитвания за създаването на p2p сесия. Но това? Най-вероятно това е някакъв неприятен бъг.
Профилирането
Това е един отличен начин да се намерят тесните места в работата на CPU. Но тук има един проблем - за пресъздаването на идеалната картина на случващото се в процесора е необходим сорс кода. А аз го нямам. Но той не ми е нужен, понеже не ми е необходима точност до микросекунди. В този случай тясното място е с продължителност невероятните четири минути.
Необходимата информация ще взема от стека, понеже това е единственият начин за изучаване на приложения със затворен код. Правим дъмп на стека на стартирания процес и намираме местоположението на указателя на текущата процесорна команда, за да можем да изградим дървото с извиквания, което може да покаже времевите интервали. След това получените по този начин времена събираме, за да изградим статистиката на случващото си. В операционната система Windows аз използвам Luke Stackwalker! Ако някой знае нещо по-добро, нека да го напише в коментарите.
Основните виновници са №1 и №2
Обикновено Luke групира еднаквите функции, но тук се налага ръчно да се преглеждат най-близките адреси, за да се разбере кога имаме един и същ адрес. И какво виждаме? Не едно, а цели две тесни места!
Навътре в заешката дупка
Взех от един приятел съвсем законно копие на популярен дизасемблер, понеже аз не мога да си позволя да купя подобно нещо, и продължих да изследвам GTA.
Всичко изглежда съвсем неправилно и странно. Това не е необичайно, понеже на практика всички високобюджетни игри имат вградена защита от реверсивно инженерство, за да се защитят от пиратите, чийтърите и модърите.
По всичко личи, че тук е използвана някаква обфускация навярно плюс криптиране, заради което повечето процесорни команди изглеждат като някаква Абракадабра. Но това няма да ни спре, понеже ние ще направим само дъмп на паметта на играта именно в момента, в който искаме да я изучим. В този момент кодът трябва да е разбираем, и да се намира в оперативната памет без обфускация.
Проблемът №1 се оказа... strlen?!
При дизасемблирането на този дъмп се вижда етикет, който не може да се разбере откъде се е взел. Прилича на strlen. Следващото надолу по стека извикване е отбелязано като vscan_fn, след което етикетите приключват и аз почти на 100% съм уверен, че това е sscanf.
Изглежда, че там нещо анализират. Дали какво е? Последвах кода стъпка по стъпка и стана ясно че това е... JSON! Невероятно, това са цели 10 MB JSON данни с почти 63 000 елемента.
...,{
"key": "WP_WCT_TINT_21_t2_v9_n2",
"price": 45000,
"statName": "CHAR_KIT_FM_PURCHASE20",
"storageType": "BITFIELD",
"bitShift": 7,
"bitSize": 1,
"category":
},
...
Какво е това? Според някои източници в интернет, това много прилича на каталог на онлайн магазин. Да предположим, че това е списъкът на всички възможни предмети и ъпдейти, които могат да бъдат закупени в GTA Online.
Но като се замислиш, 10 MB е нещо съвсем малко за един подобен магазин. А използването на sscanf, въпреки че не е оптимално, не може да бъде чак толкова лошо. Да погледнем още нещо...
В крайна сметка се оказа, че тези данни просто се сканират байт по байт докато не се достигне NULL. Това отнема наистина много, изключително много време.
Проблемът №2: използване на хеш... масив
Оказа се, че вторият виновник си се извиква почти веднага след първия. Всъщност те и двата се извикват с един и същ оператор if, както може да се види от това малко нескопосано декомпилиране:
И двата проблема са в тялото на един и същи цикъл за анализ на всички предмети в тази игра
Всички етикети са зададени от мен, понеже няма откъде да разбера какви са имената и параметрите на използваните функции.
В какво се състои втория проблем? Веднага след като необходимият вътрешноигрови предмет бъде открит, то той се записва в масив. Всеки един от тези предмети изглежда по следния начин:
struct {uint64_t *hash;
item_t *item;
} entry;
Само че какво се случва преди записа? Кодът проверява целия масив - всеки един елемент един след друг, като се сравнява хеша на предмета, за да се разбере, дали той е включен в списъка. Ако моите изчисления са верни, то при около 63 хиляди елемента това ще даде (n^2+n)/2 = (63000^2+63000)/2 = 1984531500 проверки, като повечето от тях са абсолютно безполезни. Нали хешове те са уникални, защо да не се използва например hash map?
При по-нататъшното профилиране се вижда, че процесорът се натоварва от първите два реда на този блок от код. Операторът if се изпълнява в самия край. При предпоследния ред се вмъква самия игрови предмет.
След това става даже още по-интересно. Този списък или по-точно масив от хешове преди зареждането на JSON е празен. А всички предмети в JSON са уникални! Съвсем ясно се вижда, че дори не е необходимо да се проверява, дали даден предмет е включен в този списък! Все пак използва се отделна функция за директното вмъкване на предметите и е напълно достатъчно единствено нейното използване. Какво става тук?
Proof of Concept
Всичко това е великолепно, но никой няма да ме вземе на сериозно, докато не направя необходимите тестове, които да ми дадат възможност да напиша такова гръмко заглавие на тази статия.
И така, какво ще трябва да се направи? Ще трябва да се напише .dll файл, с който да бъде инжектирана GTA, да се прихванат (това е моят код) няколко функции и готово...
Проблемът с JSON е твърде объркан, но има далеч по-лесен начин:
- Да се прихване strlen
- Да се изчака идването на дълъг ред
- Да се кешира неговото начало и неговата дължина
- И ако той отново бъде извикан в пределите на друг стринг, да се върне кешираното значение
Тоест, нещо такова:
size_t strlen_cacher(char* str){
static char* start;
static char* end;
size_t len;
const size_t cap = 20000;
// if we have a "cached" string and current pointer is within it
if (start && str >= start && str <= end) {
// calculate the new strlen
len = end - str;
// if we're near the end, unload self
// we don't want to mess something else up
if (len < cap / 2) MH_DisableHook((LPVOID)strlen_addr); // super-fast return! return len; } // count the actual length // we need at least one measurement of the large JSON // or normal strlen for other strings len = builtin_strlen(str); // if it was the really long string // save it's start and end addresses if (len > cap) {
start = str;
end = str + len;
}
// slow, boring return
return len;
}
Що се отнася до проблема с хеш масива, то тук нещата са по-опростени - може напълно да бъде изпусната дублиращата се проверка и игровите предмети да се вмъкват директно, понеже знаем, че техните значения са уникални:
char __fastcall netcat_insert_dedupe_hooked(uint64_t catalog, uint64_t* key, uint64_t* item){
// didn't bother reversing the structure
uint64_t not_a_hashmap = catalog + 88;
// no idea what this does, but repeat what the original did
if (!(*(uint8_t(__fastcall**)(uint64_t*))(*item + 48))(item))
return 0;
// insert directly
netcat_insert_direct(not_a_hashmap, key, &item);
// remove hooks when the last item's hash is hit
// and unload the .dll, we are done here :)
if (*key == 0x7FFFD6BE) {
MH_DisableHook((LPVOID)netcat_insert_dedupe_addr);
unload();
}
return 1;
}
Резултатите
Дали се получи нещо? И още как:
- Време на зареждане на онлайн режима - под 6 минути
- Време на зареждане само с пача, който премахва дублиращата проверка - 4 минути и 30 секунди
- Време на зареждане само с пача на JSON парсъра: 2 минути и 50 секунди
- Време на зареждане с двата пача: 1 минута и 50 секунди
Лесно можем да изчислим, че (6*60 — (1*60+50)) / (6*60) - времето на зареждане е намаляло с 69,4%, което е един отличен резултат.
Всичко това работи при всякакви компютърни конфигурации и аз не разбирам как така програмистите на R* не са видели тези проблеми в продължение на тези дълги години. Момчета, оправете си кода!
Коментари
Моля, регистрирайте се от TУК!
Ако вече имате регистрация, натиснете ТУК!
Няма коментари към тази новина !
Последни коментари