天天看點

[譯]我是如何将GTA線上模式的加載時間縮短70%的

[譯]我是如何将GTA線上模式的加載時間縮短70%的

譯注: 最近在網上發現了一篇有意思的文章, 一個國外大神受不了GTA5線上模式的加載時間, 一怒之下反彙編了GTA5的源碼, 并最終發現了問題的原因是因為R星寫了一段非常爛的代碼來讀取JSON! 随後大神制作了優化更新檔将加載時間縮短了70%, 并開源在GITHUB上! 他将從定位問題, 分析問題, 到解決問題的完整過程記錄下來寫成了一篇幹貨滿滿的技術文章. 文章用詞幽默, 充滿了對R星的吐槽, 一經發出很快登上了HackerNews的排行榜, 可見其熱度.

WAKU将其完整地翻譯為中文, 供大家學習交流, 翻譯使用意譯方式, 水準有限, 有錯誤請指出:)

原文位址: https://nee.lv/2021/02/28/How-I-cut-GTA-Online-loading-times-by-70/

原作者: T0ST

日期: 2021-02-28

GTA的線上模式.以漫長加載時間而臭名昭着.當我再次進入遊戲來完成一些新的搶劫任務時,我震驚地發現它仍然像7年前釋出的那天一樣慢.

是時候了.是時候來研究下這個問題了.

#偵察

首先,我想看看是否有人已經解決了這個問題.我發現的大多數結果都是些個人經驗, 說遊戲如此的複雜,需要加載這麼長時間,和一些說P2P架構如何垃圾的故事(不是說它不是),也有建議先加載故事模式然後再進入線上模式, 還有能在啟動時跳過R星那個LOGO視訊的Mod.繼續深入的閱讀,我發現可以通過組合這些方法來節省10到30秒!

此時在我的電腦上......

#測試

故事模式加載時間:~1分10秒
線上模式加載時間:~6分鐘
啟動菜單禁用了,從R*的LOGO一直到進入遊戲(未計算社交俱樂部登入時間).

老款但正經的CPU:AMD FX-8350
便宜的SSD:金士頓SA400S37120G
必須得有的記憶體:兩條 金士頓 8192 MB(DDR3-1337)99U5471
不錯的GPU:NVIDIA GeForce GTX 1070
           

我知道我的配置過時了,但為啥需要6倍的時間才能進入線上模式?我用"先故事, 然後線上"這種加載技術也看不出有任何差別, 之前其他人已經做過類似測試. 即使這招确實好使,結果也不會很明顯.

我(并不)孤單

如果這個調查可信,那麼這個問題就足以讓超過80%的玩家惱火.7年了, R星!

[譯]我是如何将GTA線上模式的加載時間縮短70%的

在四處尋找看誰是那20%能在3分鐘内加載完的幸運兒時, 我看到了用高端遊戲PC進行的一 些 測試, 能達到大約2分鐘的加載時間!2分鐘!讓我死吧!

看起來硬體似乎是關鍵,但事情并不是這麼簡單......

他們的故事模式為何仍然需要加載近一分鐘?(随便說一下M.2那個沒有計算啟動LOGO的時間.)另外, 從故事到線上的加載時間隻花了他們1分多, 而我是5分多.我知道他們的硬體規格更好,但肯定沒好到5倍.

#高精度測量

借助任務管理器這種強大的工具, 我開始調查哪塊兒可能是瓶頸.

[譯]我是如何将GTA線上模式的加載時間縮短70%的

在花了一分鐘用來加載故事和線上模式使用的共同資源後(這時間與高端PC差不多), GTA決定用4分鐘挑戰一下我電腦單核的極限,除此之外就沒有别的了.

磁盤使用?沒有!網絡使用?有一點,但在幾秒鐘後,它基本上下降到零(除了加載那個旋轉的資訊橫幅). GPU使用?零.記憶體使用情況?平常平穩......

那是什麼呢,是在挖礦嗎還是什麼?我感覺到了一些代碼.非常糟糕的代碼.

#單線程

雖然我的舊AMD CPU有8個核心而且工作良好,但它是以前生産的.在AMD的單線程性能落後于英特爾的年代.這可能沒法解釋所有這些加載時間的差異,但應該能解釋大部分了.

奇怪的是它隻使用CPU.我本來以為會有大量的磁盤讀取或者在P2P網絡中進行頻繁的網絡請求.但瞅現在這個德性? 應該是有BUG了.

#分析

分析器是尋找CPU瓶頸的一種好方法.但是有一個問題 - 它們中的大多數都依賴于源代碼來洞悉程序中正在發生的事情.我沒有源代碼.而我也不需要微秒級完美的讀數 - 我有4分鐘的瓶頸呢.

使用堆棧采樣:對于沒有源代碼的應用程式,隻有這一個選項.定期轉儲(Dump)正在運作程序的堆棧和目前指令指針的位置來建立一個調用樹.然後将它們添加到目前的統計資訊中.我隻知道一個能在Windows幹這個事兒的分析器(可能孤陋寡聞了).它已經超過10年沒更新了.它就是Luke Stackwalker!有沒有人, 拜托了, 請給這個項目一些愛:)

[譯]我是如何将GTA線上模式的加載時間縮短70%的

通常Luke會将相同的函數分組在一起,但是因為我沒有調試符号,我不得不用肉眼來看周圍的位址,以猜測它是否是同一個.我們看到了啥?不是一個瓶頸,而是倆!

#深入虎穴

借用了我朋友的業界标竿的正版反彙編器(不,我确實負擔不起這玩意......我這兩天得學學Ghidra了(譯注:一個開源的逆向工程工具)),我把GTA開了瓢.

[譯]我是如何将GTA線上模式的加載時間縮短70%的

看起來不太妙啊.我們知道大多數知名遊戲都有内置保護,防止逆向工程,以遠離盜版,作弊器和修改器.盡管也沒怎麼防住.

這裡似乎有某種混淆/加密,使用花指令替換了大多數正常的指令.不過不用擔心,我們隻是需要在遊戲運作我們關心的那塊兒時轉儲遊戲的記憶體. 而在執行之前, 這些指令肯定是要還原為正常指令的. 我正好手頭有Process Dump, 是以我用它了, 但是有很多其他的工具也可以完成這個事兒.

#問題1: 就是… strlen?!

通過反彙編該"輕微混淆"的轉儲檔案顯示, 其中一個位址被打上标記了!這是

strlen

?沿調用堆棧向下找, 下一個被标記的

vscan_fn

,再之後,标簽結束了,但我很自信它應該是sscanf.

[譯]我是如何将GTA線上模式的加載時間縮短70%的

這是在解析什麼東西.解析啥呢?跟這些反彙編糾纏起來沒完沒了, 是以我決定使用x64dbg來轉儲一些程序的采樣.在一些調試步進後, 結果出來了那就是......JSON!他們正在解析JSON.一個有6萬3千個項目的10MB的JSON.

{
    "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線上模式購買的所有可能項目和更新的清單.

這裡澄清一下:我認為這些是遊戲中可購買的物品,與微交易沒關系.

但10MB?沒事兒!使用

sscanf

可能不是最優的,但肯定不是那麼糟糕?好吧…

[譯]我是如何将GTA線上模式的加載時間縮短70%的

是的,這會花一段時間......公平的講,我之前也不知道大部分

sscanf

的實作都調用了

strlen

,是以我也不能怪罪寫這個的開發者.我會假設它隻是一個位元組一個位元組的掃描,碰到NULL後停止.

#問題2: 讓我們使用哈希- ... 數組?

看起來第二個罪魁禍首是緊接着第一個被調用的. 從這個醜陋的反編譯代碼中能看到它們是在同一個if語句裡被同等調用的.

[譯]我是如何将GTA線上模式的加載時間縮短70%的

所有的标簽都是我起的,不知道實際調用的函數/參數是什麼.

第二個問題?在解析一個項目後,将它存儲在數組中(或内聯的C++清單?不确定).每個條目看起來長這樣:

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

但在存儲之前?它一個接一個地檢查整個數組,将項目的哈希值進行比較,以檢查它是否已經在清單中.大約有6萬3000個項目,如果我沒算錯的話就是

(n^2+n)/2 =(63000^2+63000)/2 = 1984531500

次檢查.絕大多數檢查都沒有用. 你已經有了唯一的哈希值為什麼不使用哈希表.

[譯]我是如何将GTA線上模式的加載時間縮短70%的

我在逆向的時候将它命名為"哈希表",但顯然它"不是一個哈希表".更絕的是.在加載JSON之前,這個"哈希數組清單"是空的.JSON裡所有項目都是唯一的!他們甚至不需要檢查它是否在清單中!他們甚至可以直接插入項目!用啊!真是的, 搞毛呢!?

#可行性驗證(PoC)

挺好,但是沒人會把我當回事, 除非我測試一下,這樣我就可以給這個文章起個騙點選的标題.

計劃?寫一個

.dll

,注入進GTA,hook一些函數,???,獲利

JSON問題有點棘手,我無法實際替換他們的解析器.用一個不依賴

strlen

sscanf

更現實一些.但是還有一種更簡單的辦法.

  • hook 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) {
    // 計算新的strlen
    len = end - str;

    // 快結束了, 解除安裝自己
    // 我們不想把其它東西搞砸
    if (len < cap / 2)
      MH_DisableHook((LPVOID)strlen_addr);

    // 超快的傳回!
    return len;
  }

  // 計算實際長度
  // 我們至少需要算一次這個巨大的JSON
  // 或者對其它字元串使用普通的strlen
  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;
}
           

可行性驗證完整代碼在這裡.

#結果

是以, 好使了嗎?

原線上模式加載時間:        大概6分鐘
隻打了重複檢查更新檔的時間:   4分30秒
隻打了JSON解析器更新檔的時間: 2分50秒
兩個都打的時間:           1分50秒

(6*60 - (1*60+50)) / (6*60) = 69.4% 加載時間改善(棒!)
           

我去,成功了! 😃)

也有可能這不會解決所有人的加載時間 - 不同系統可能還有其他瓶頸,但這是一個如此巨大的漏洞,我不知道為什麼R*這些年來都沒有注意到.

#長求總

  • 在啟動GTA線上模式時有一個單線程CPU瓶頸
  • 看起來GTA在解析一個10MB的JSON檔案時挺費勁
  • JSON解析器自身實作的很爛, 并且
  • 在解析之後有一個很慢的項目去重步驟

#R*請修複吧

如果Rockstar看到了本文:這個問題一個開發應該用不了一天就能解決.幹點事兒吧 :<

你可以要麼切換到哈希表來去重, 要麼在啟動時完全跳過它, 這樣可以更快. 對于JSON解析器 - 隻需換一個性能更好的.我想沒有更簡單的辦法了.

謝謝 ❤️

#小更新

我本來隻期待能有一點兒關注,但沒想到這麼火!在登上HN的排行榜後,這篇文章像野火一樣傳播!謝謝你們的潮水般的回應:)

如果還有興趣的話, 我會繼續寫一些,但不要指望會很快 - 這裡有很多運氣成份.

一些人建議将這篇文章甩給Rockstar的支援 - 可别!我相信他們現在已經看到了.繼續搞下去其他人的問題可能就沉了.我覺得社交媒體應該是公平的.

有一些HN評論建議我添加一個捐贈按鈕,因為他們想給我買瓶啤酒(謝謝!)是以我在頁腳中放了一個連結.

感謝閱讀和所有的支援:)

版權所有 © 2021 t0st

如果喜歡的話請考慮給原作者買杯咖啡(譯注: 不是啤酒嗎? 到底喝啥)