[译]我是如何将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星!
在四处寻找看谁是那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问题有点棘手,我无法实际替换他们的解析器.用一个不依赖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% 加载时间改善(棒!)
我去,成功了!