iliazeus

илья, иль не я

Как я заставил GTA Online загружаться на 70% быстрее

GTA Online печально известна своими чудовищно медленными загрузками. Решив снова поиграть в нее ради пары новых миссий, я обнаружил (вот сюрприз!), что все настолько же плохо, как и семь лет назад, в день ее релиза.

Но теперь пришло время разобраться с этим раз и навсегда.

Разведка

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

Бенчмарки

Story mode load time:  ~1m 10s
Online mode load time: ~6m flat
Startup menu disabled, time from R* logo until in-game (social club login time isn't counted).

Old but decent CPU:   AMD FX-8350
Cheap-o SSD:          KINGSTON SA400S37120G
We have to have RAM:  2x Kingston 8192 MB (DDR3-1337) 99U5471
Good-ish GPU:         NVIDIA GeForce GTX 1070

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

Я такой (не) один

Если верить этому опросу, такие проблемы раздражают более 80% аудитории игры. И так уже семь лет, Rockstar!

Покопавшись в интернете в поисках тех 20% счастливчиков, загружающихся за 3 минуты или меньше, я обнаружил бенчмарки, в которых топовые ПК грузят игру за 2-с-гаком минуты. Я бы сделал что угодно (с игрой) ради таких быстрых загрузок! И все-таки, хотя время и зависит от железа, что-то с ним неладно...

Почему загрузка одиночного режима даже на топовых ПК занимает все еще около минуты? Более того, примерно столько же нужно, чтобы загрузиться из одиночного режима в сетевой. Я знаю, что мой компьютер не самый быстрый, но не в пять же раз.

Высокоточные измерения

И так, заручившись поддержкой несравненного Диспетчера Задач, я начал свое расследование.

Общие для одиночной и сетевой игры ресурсы загрузились примерно за минуту, что сходится с результатами на топовых ПК. Но после этого GTA вдруг решила четыре минуты погреть ровно одно ядро моего процессора.

Дисковая активность? Ноль! Сетевая активность? Небольшая, и сходит на нет через несколько секунд, если не считать периодической подгрузки рекламных баннеров. Использования GPU? Ноль. Оперативной памяти? Не меняется...

Что там, черт возми, происходит? Майнинг крипты? Я чую запах говнокода.

Однопоточность

Мой восьмиядерный процессор от AMD все еще неплох по современным меркам, но он уже довольно стар. Из тех времен, когда их однопоточная производительность сильно отставала от Intel. Это, на самом деле, могло объяснить такую разницу во времени загрузки.

Но подозрительно то, что игра использует только ЦП. Не диск для подгрузки ресурсов. Не сеть для установки p2p-соединения. Очень похоже, что я нашел какой-то баг.

Профилирование

Легче всего искать горячие участки кода, вооружившись профилировщиком. Проблема, конечно, в том, что большинство из них действует путем модификации исходного кода программы, внедряясь в нее до сборки, ради повышения точности измерений. А исходного кода у меня нет. С другой стороны, точные измерения мне и не нужны — в моем случае, горячий код выполняется минутами.

Очевидный выход в таких условиях — сэмплирующий профилировщик. Такие инструменты делают периодические дампы стека во время работы программы, чтобы по ним получить статистику времени выполнения кода. Я знаю только один такой профилировщик (хотя, может, есть и другие), который работает под Windows. Хоть он и не обновлялся уже больше десяти лет, Luke Stackwalker все еще заслуживает слова моей благодарности.

Как правило, Luke сгруппировал бы вызовы одной и той же функции в одну запись. Но у меня нет отладочных символов для GTA Online, поэтому я попытался посмотреть сам на те адреса, что рядом. И что же я увидел? Не одно узкое место, а целых два!

Кроличья нора

Одолжив у приятеля его полностью легальную копию всем известного дизассемблера (надо таки учиться использовать ghidra), я принялся разбирать GTA на части.

Хм, что-то не то. Похоже, что код обфусцирован, как и у многих других AAA-видеоигр. Что ж, нам всего лишь нужно достать интересный нам код напрямую из памяти запущенной игры. Рано или поздно ей придется их расшифровать для исполнения. Откопав в своих закромах Process Dump, я обнаружил кое-что интересное.

Проблема раз: ...strlen?!

Для одного из "горячих" адресов дизассемблер вытянул откуда-то имя! И это… strlen? Выше по стеку можно найти vscan_fn, а ее, я почти уверен, вызывает sscanf.

Похоже, игра что-то парсит. Но что? Распутывая вывод дизассемблера, можно потратить вечность, поэтому я просто сделал пару дампов с помощью x64dbg. И оказалось, что парсится… JSON! Да, самый обычный JSON. Если быть точнее, 10 мегабайт JSON-массива с 63 тысячами элементов.

...,
{
    "key": "WP_WCT_TINT_21_t2_v9_n2",
    "price": 45000,
    "statName": "CHAR_KIT_FM_PURCHASE20",
    "storageType": "BITFIELD",
    "bitShift": 7,
    "bitSize": 1,
    "category": ["CATEGORY_WEAPON_MOD"]
},
...

Предполагаю, это список всех вещей и апгрейдов, доступных к покупки в GTA Online за внутриигровую валюту.

Тем не менее, скажете вы, 10 мегабайт — не так уж и много. И sscanf — конечно, не лучший выбор, но не может же все быть настолько плохо? Что ж...

Мда, не самый оптимальный алгоритм. Честно говоря, я и сам не знал, что большинство реализаций sscanf вызывают внутри себя strlen, поэтому не виню того, кто написал этот код. Я думал, что sscanf просто читает строку побайтово, пока не наткнется на '\0'.

Проблема два: хэш-… списки?

Оказалось, что следующий виновник вызывается совсем рядом. В декомпилированном исходнике ниже они в одном и том же блоке if.

Все имена переменных моего авторства. Не имею понятия, как они называются на самом деле.

Вторая проблема в том, что каждый считанный объект хранится в довольно странной структуре. Каждый ее элемент выглядит примерно так, как описано ниже. Возможно, это то, во что компилируется связный список из C++? Я не знаю точно.

struct {
    uint64_t  hash;
    item_t   *item;
} entry;

Но прежде чем сохранить очередной объект, игра проверят все уже имеющиеся в списке на предмет совпадения хэша. С 63 тысячами элементов, это требует порядка (n^2+n)/2 = (63000^2+63000)/2 = 1984531500 сравнений. И большая их часть абсолютна бесполезна. У вас есть хэши, почему бы не использовать хэш-таблицу?

В декомпилированных исходниках я назвал это hashmap, хотя стоило бы назвать clearly_not_a_hashmap. Но на самом деле, все еще веселее. Перед загрузкой JSON, эта структура пуста. А в самом JSON все объекты уникальны! Проверки хэшей попросту не нужны! У них даже есть отдельная функция для добавления элемента без проверок! WTF?!

Proof-of-concept

Это все, конечно, весело, но для максимально кликбейтного заголовка мне нужно было написать фикс.

План был такой: написать .dll, инжектнуть ее в GTA, установить хуки, ???, profit.

Проблема с JSON довольно неприятна. Я не мог заменить парсер без огромных усилий. Более реалистичный вариант — подменить sscanf на что-то, что не использует strlen. Но я поступил еще проще: поставил хук на strlen, и кешировал его результаты для достаточно длинных строк. Выглядит это примерно так:

size_t strlen_cacher(char* str)
{
  static char* start;
  static char* end;
  size_t len;
  const size_t cap = 20000;

  // если у нас в "кэше" есть строка, и текущий указатель где-то внутри нее
  if (start && str >= start && str <= end) {
    // посчитать новую длину
    len = end - str;

    // если мы рядом с концом строки, отключить хук
    // чтобы не напортачить ненароком
    if (len < cap / 2)
      MH_DisableHook((LPVOID)strlen_addr);

    return len;
  }

  // вызвать настоящий strlen
  // мы должны сделать это хотя бы раз для нашего огромного JSON
  len = builtin_strlen(str);

  // если строка была достаточно длинной
  // сохранить указатели на начало и конец
  if (len > cap) {
    start = str;
    end = str + len;
  }

  return len;
}

С "хэш-списком" все проще: все элементы уникальны, поэтому просто будем добавлять их без всяких проверок.

char __fastcall netcat_insert_dedupe_hooked(uint64_t catalog, uint64_t* key, uint64_t* item)
{
  // я забил на то, чтобы реверсить всю структуру
  uint64_t not_a_hashmap = catalog + 88;

  // хз, что это, но это было в оригинале
  if (!(*(uint8_t(__fastcall**)(uint64_t*))(*item + 48))(item))
    return 0;

  // вставить без проверок
  netcat_insert_direct(not_a_hashmap, key, &item);

  // после добавления последнего элемента
  // отключить хук и выгрузить .dll
  if (*key == 0x7FFFD6BE) {
    MH_DisableHook((LPVOID)netcat_insert_dedupe_addr);
    unload();
  }

  return 1;
}

Полный исходный код здесь.

Результаты

Сработало ли?

Original online mode load time:        ~6m flat
Time with only duplication check patch: 4m 30s
Time with only JSON parser patch:       2m 50s
Time with both issues patched:          1m 50s

(6*60 - (1*60+50)) / (6*60) = 69.4% load time improvement (nice!)

Да, черт возьми!

Этот фикс почти наверняка не исправит всех проблем с загрузкой. На других системах узкие места могут быть другими. Но найденные мной вещи были настолько очевидны, что я не знаю, почему Rockstar их до сих пор не пофиксили.

TL;DR

R* please fix

Если это читает кто-то из Rockstar: эти проблемы не должно быть так сложно пофиксить. Пожалуйста, сделайте что-нибудь.

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

заранее спасибки <3