[译]我是如何将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星!

在四处寻找看谁是那20%能在3分钟内加载完的幸运儿时, 我看到了用高端游戏PC进行的 测试, 能达到大约2分钟的加载时间!2分钟!让我死吧!

看起来硬件似乎是关键,但事情并不是这么简单......

他们的故事模式为何仍然需要加载近一分钟?(随便说一下M.2那个没有计算启动LOGO的时间.)另外, 从故事到在线的加载时间只花了他们1分多, 而我是5分多.我知道他们的硬件规格更好,但肯定没好到5倍.

#高精度测量

借助任务管理器这种强大的工具, 我开始调查哪块儿可能是瓶颈.

在花了一分钟用来加载故事和在线模式使用的共同资源后(这时间与高端PC差不多), GTA决定用4分钟挑战一下我电脑单核的极限,除此之外就没有别的了.

磁盘使用?没有!网络使用?有一点,但在几秒钟后,它基本上下降到零(除了加载那个旋转的信息横幅). GPU使用?零.内存使用情况?平常平稳......

那是什么呢,是在挖矿吗还是什么?我感觉到了一些代码.非常糟糕的代码.

#单线程

虽然我的旧AMD CPU有8个核心而且工作良好,但它是以前生产的.在AMD的单线程性能落后于英特尔的年代.这可能没法解释所有这些加载时间的差异,但应该能解释大部分了.

奇怪的是它只使用CPU.我本来以为会有大量的磁盘读取或者在P2P网络中进行频繁的网络请求.但瞅现在这个德性? 应该是有BUG了.

#分析

分析器是寻找CPU瓶颈的一种好方法.但是有一个问题 - 它们中的大多数都依赖于源代码来洞悉进程中正在发生的事情.我没有源代码.而我也不需要微秒级完美的读数 - 我有4分钟的瓶颈呢.

使用堆栈采样:对于没有源代码的应用程序,只有这一个选项.定期转储(Dump)正在运行进程的堆栈和当前指令指针的位置来创建一个调用树.然后将它们添加到当前的统计信息中.我只知道一个能在Windows干这个事儿的分析器(可能孤陋寡闻了).它已经超过10年没更新了.它就是Luke Stackwalker!有没有人, 拜托了, 请给这个项目一些爱:)

通常Luke会将相同的函数分组在一起,但是因为我没有调试符号,我不得不用肉眼来看周围的地址,以猜测它是否是同一个.我们看到了啥?不是一个瓶颈,而是俩!

#深入虎穴

借用了我朋友的业界标竿的正版反汇编器(不,我确实负担不起这玩意......我这两天得学学Ghidra了(译注:一个开源的逆向工程工具)),我把GTA开了瓢.

看起来不太妙啊.我们知道大多数知名游戏都有内置保护,防止逆向工程,以远离盗版,作弊器和修改器.尽管也没怎么防住.

这里似乎有某种混淆/加密,使用花指令替换了大多数正常的指令.不过不用担心,我们只是需要在游戏运行我们关心的那块儿时转储游戏的内存. 而在执行之前, 这些指令肯定是要还原为正常指令的. 我正好手头有Process Dump, 所以我用它了, 但是有很多其他的工具也可以完成这个事儿.

#问题1: 就是… strlen?!

通过反汇编该"轻微混淆"的转储文件显示, 其中一个地址被打上标记了!这是strlen?沿调用堆栈向下找, 下一个被标记的vscan_fn,再之后,标签结束了,但我很自信它应该是sscanf.

这是在解析什么东西.解析啥呢?跟这些反汇编纠缠起来没完没了, 所以我决定使用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可能不是最优的,但肯定不是那么糟糕?好吧…

是的,这会花一段时间......公平的讲,我之前也不知道大部分sscanf的实现都调用了strlen,所以我也不能怪罪写这个的开发者.我会假设它只是一个字节一个字节的扫描,碰到NULL后停止.

#问题2: 让我们使用哈希- ... 数组?

看起来第二个罪魁祸首是紧接着第一个被调用的. 从这个丑陋的反编译代码中能看到它们是在同一个if语句里被同等调用的.

所有的标签都是我起的,不知道实际调用的函数/参数是什么.

第二个问题?在解析一个项目后,将它存储在数组中(或内联的C++列表?不确定).每个条目看起来长这样:

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

但在存储之前?它一个接一个地检查整个数组,将项目的哈希值进行比较,以检查它是否已经在列表中.大约有6万3000个项目,如果我没算错的话就是(n^2+n)/2 =(63000^2+63000)/2 = 1984531500次检查.绝大多数检查都没有用. 你已经有了唯一的哈希值为什么不使用哈希表.

我在逆向的时候将它命名为"哈希表",但显然它"不是一个哈希表".更绝的是.在加载JSON之前,这个"哈希数组列表"是空的.JSON里所有项目都是唯一的!他们甚至不需要检查它是否在列表中!他们甚至可以直接插入项目!用啊!真是的, 搞毛呢!?

#可行性验证(PoC)

挺好,但是没人会把我当回事, 除非我测试一下,这样我就可以给这个帖子起个骗点击的标题.

计划?写一个.dll,注入进GTA,hook一些函数,???,获利

JSON问题有点棘手,我无法实际替换他们的解析器.用一个不依赖strlensscanf更现实一些.但是还有一种更简单的办法.

  • 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

如果喜欢的话请考虑给原作者买杯咖啡(译注: 不是啤酒吗? 到底喝啥)

[译]我是如何将GTA在线模式的加载时间缩短70%的的更多相关文章

  1. IView入门练习~CDN模式全局加载JS

    关于 iView iView 是一套基于 Vue.js 的开源 UI 组件库,主要服务于 PC 界面的中后台产品. 特性 高质量.功能丰富 友好的 API ,自由灵活地使用空间 细致.漂亮的 UI 事 ...

  2. Debug模式下加载文件,运行程序异常的慢

    今天在进行单元测试的时候,debug模式下加载速度很慢,但是run模式下速度很快. 原因:在debug模式下,断点位置不当,解决办法 移除编译器中的所有断点.

  3. php命令行模式下加载的php.ini文件可能和web模式下加载的php.ini不一致

    php命令行模式下加载的php.ini文件可能和web模式下加载的php.ini不一致 命令行下查看加载的php.ini的路径: php -i|grep php.ini web模式下查看: <? ...

  4. 在线配置热加载配置 go-kratos.dev 监听key

    paladin https://v1.go-kratos.dev/#/config-paladin example Service(在线配置热加载配置) # service.go type Servi ...

  5. Django开发模式会加载两次settings文件导致RotatingFileHandlerError

    当使用RotatingFileHandler作为django的日志处理器的时候,会报: Traceback (most recent call last): File "C:\Python2 ...

  6. x86架构:保护模式下加载并运行用户程序

    本章的代码分3个模块: MBR 引导:加载内核core程序 core:包含内核代码段(从磁盘加载用户程序并重定位).内核数据段(存放api名称.临时缓冲.字符串等).API段(供用户程序调用) 用户程 ...

  7. 解决ThinkPHP中开启调试模式无法加载模块的问题。

    刚开始学习ThinkPHP就遇到这种问题,还是自己粗心. 错误如下: 原因:开启调试模式,区分大小写的,要把模块名首字母大写就OK了.也就是: [plain] view plain copy http ...

  8. iOS-设计模式-懒加载

    一.为什么要懒加载? 答: iPhone设备内存有限,如果在程序在启动后就一次性加载将来会用到的所有资源,那么久可能会耗尽iOS设备的内存.这些资源例如大量的数据,图片,音频,过多的控件等. 二.懒加 ...

  9. 【译】12. Java反射——类的动态加载和重新加载

    原文地址:http://tutorials.jenkov.com/java-reflection/dynamic-class-loading-reloading.html 博主最近比较忙,争取每周翻译 ...

随机推荐

  1. Codeforces Round #565 (Div. 3) C. Lose it! (思维)

    题意:给你一串只含\(4,8,15,16,23,42\)的序列,如果它满足长度是\(6\)的倍数并且有\(\frac {k}{6}\)个子序列是\([4,8,15,16,23,42]\),则定义它是好 ...

  2. WSL2 Ubuntu apt-get update失败

    情况: 这个问题在github上也有讨论:https://github.com/microsoft/WSL/issues/4342 不过经过我的尝试,是DNS问题,这是默认的配置: 这个配置来自win ...

  3. 并发编程之java内存模型(Java Memory Model ,JMM)

    一.图例 0.两个概念 Heap(堆):运行时的数据区,由垃圾回收负责,运行时分配内存(所以慢),对象存放在堆上 如果两个线程,同时调用同一个变量,怎两个线程都拥有,该对象的私有拷贝 (可以看一下,T ...

  4. oslab oranges 一个操作系统的实现 实验四 认识保护模式(三):中断异常

    实验目的: 理解中断与异常机制的实现机理 对应章节:第三章3.4节,3.5节 实验内容: 1. 理解中断与异常的机制 2. 调试8259A的编程基本例程 3. 调试时钟中断例程 4. 建立IDT,实现 ...

  5. 7A - Kalevitch and Chess

    A. Kalevitch and Chess time limit per test 2 seconds memory limit per test 64 megabytes input standa ...

  6. P1337 [JSOI2004]平衡点(模拟退火)题解

    题意: 如图:有n个重物,每个重物系在一条足够长的绳子上.每条绳子自上而下穿过桌面上的洞,然后系在一起.图中X处就是公共的绳结.假设绳子是完全弹性的(不会造成能量损失),桌子足够高(因而重物不会垂到地 ...

  7. MarkDown(文本编译器)

    MarkDown(一种高效的文本编译器) 推荐使用Typora 点击此处下载 使用方法 1. 首先创建一个文本文件xxx.txt. 2. 然后修改文件后缀为xxx.md.(可以记做玛德...) 3. ...

  8. bash variables plus operator All In One

    bash variables plus operator All In One Errors missing pass params #!/usr/bin/env bash # echo emoji ...

  9. Ajax & JSONP 原理

    Ajax & JSONP 原理 AJAX不是JavaScript的规范,它只是一个哥们"发明"的缩写:Asynchronous JavaScript and XML,意思就 ...

  10. ServerLess & MongoDB Atlas & REST API

    ServerLess & MongoDB Atlas & REST API ServerLess, Nodejs, MongoDB Atlas cloud 构建 REST API ht ...