[译]我是如何将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问题有点棘手,我无法实际替换他们的解析器.用一个不依赖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

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

posted @ 2021-03-03 14:23  WAKU  阅读(5035)  评论(1编辑  收藏  举报