21 KiB
title, date, slug, description, tags, draft
| title | date | slug | description | tags | draft | ||||
|---|---|---|---|---|---|---|---|---|---|
| 《奥日 2》加载闪退排查记:六月靠猜,九月靠测 | 2026-09-23 | ori-wotw-crash-troubleshooting-story | 同一个闪退,我排查了两次。六月换显卡、禁声卡、刷系统更新,还把虚拟扬声器写成了根因;九月我换了套办法,最后改掉 8 个字节。这是一份两轮排查的对比复盘。 |
|
true |
写在前面
这篇复盘想记录的不是"奥日 2 怎么修",而是同一个故障,我用两套完全不同的办法排查了两次,结果差了多少。
六月的我,靠"试"。九月的我,靠"测"。
中间隔着三个月,也隔着一次方法论上的重来。下面把两轮分开写,最后再对比它们差在哪。
补充一句诚实的说明:六月的记录里,只剩下我自己那一侧说过的话,完整的对话没有留存下来。所以写六月那一半时,我描述的是我当时看到的现象、我的做法和最后的结果,而不是去复述谁当时建议了什么。
一、问题:表现其实一直没变
先把故障说清楚。六月和九月看到的是同一件事,所以我只写一遍。
《奥日 2》(Ori and the Will of the Wisps)在 Windows 上有一个很不讲道理的表现:
- 加载画面里小精灵还在跑步,游戏突然闪退。 没有报错弹窗,没有卡顿预警,就是"啪"一下回到桌面。
- 偶尔能进去,但完全没有声音。 更折磨人的是这种——你以为这次成功了,结果发现整个世界是静音的。
- 两个不同版本的安装目录,都会崩。
- 同一台机器上的 VMware 虚拟机,却能正常玩。
最后这条最让人抓狂:同一个系统版本,虚拟机跑得起来,宿主机跑不起来。这几乎在暗示"问题出在宿主机的某个环境差异上",但又说不清是哪个。
就是这样一件事,我折腾了三个月,甚至一度退了款——游戏文件倒是还留在硬盘上。
关键点是:这两个月里,故障本身没有任何变化。 变化的是我钻进去的方式。
二、上半场:六月 —— 我用"试"的办法
2.1 当时的思路:可疑就试
六月那轮的基本路数是"列清单、逐个试"。手上的候选办法很多,而且每一个单独看都挺有道理:
- 更新显卡驱动(后来又用 DDU 彻底重装了两次)
- 换显卡——直接把 RX 9070 XT 禁用,改用 Tesla P4 跑
- 禁用可疑的虚拟音频设备
- 调整音频接口和采样率
- 限制 CPU 可用核心(试过好几个配置)
- 跑 DXVK,换掉图形渲染路径
- 刷系统更新
每一个都试了。每一个都没解决。
2.2 六月其实摸到过正确方向
这部分现在回头看最有意思:六月并不是完全没碰到点上。
6 月 30 日,我当时已经把这条线索交给了 AI 要求独立分析:
Wwise 创建流式 I/O 设备失败
也就是说,"音频引擎初始化"这个方向,六月的我已经看到了。
但问题在于:我没有把它变成任何一条可以核对的东西。
它停在了"我知道这里报错了"这个层面。往下本该问的是:
- 这个失败发生在哪个函数?
- 调用时的参数是什么?
- 返回值和错误码是什么?
- 失败的下一步程序做了什么?
这四个问题,六月一个都没问。
2.3 六月结出的果:一个写进文档的错误根因
六月那轮最后留下的东西,是我从回收站里翻出来的一份记录。上面白纸黑字写着:
Steam Streaming Speakers 虚拟扬声器干扰了 Wwise 音频引擎的设备枚举。
随后:虚拟扬声器返回无效参数 → 流式 I/O 设备创建失败 → 虚函数表损坏 → 跳转到堆地址。
有效修复(关键操作,勿还原)
我把禁用这个虚拟扬声器,当成根因和有效修复写进了文档,还标注了"勿还原"。
而紧接着的记录是:能进游戏了,还是没有声音;再开一次,又闪退。
这就是问题的核心:一个本来就间歇性发生的故障,你改点什么,下一次它恰好没崩——这什么也证明不了。
"禁用扬声器后能进""禁用两个服务后连续两次能进""锁核后能进去——但没声音",这类记录在六月出现了好几次。它们最多说明"这个改动可能影响了触发概率",绝推不出"这个设备就是根因"。
更糟的是,那条"虚函数表损坏"的链条,没有任何一条设备枚举返回值或者 API 跟踪来支撑它。它听起来很专业,但它是猜的。
2.4 六月的代价
除了没修好,六月还付出了两笔额外的账:
第一笔:用系统更新当诊断实验。 6 月 16 日,两次系统更新后机器直接无法正常启动,靠系统还原才救回来;18 日更新终于成功,游戏照崩不误。
第二笔:虚拟机对照混进了太多变量。 虚拟机确实能出声,但旧日志显示:它先是因为 VMware 共享目录里的音频资源路径失败而无声,之后我又把游戏文件从网络路径拷到了本地。"更新"和"换路径"是同时发生的,凭什么把功劳算给更新?
六月这一轮,总结成一句话就是:
改了很多变量,但一个都没控制住。
三、下半场:九月 —— 我换成"测"的办法
三个月后重新开始,这次定了一条规矩:先别动手,先看证据。
下面五步,是六月那四个问题(哪个函数/什么参数/什么返回值/失败之后怎样)一步一步补上答案的过程。
第 1 步:把日志里的"噪音"和"重复故障"分开
Unity 日志里塞满了警告,比如大量 The referenced script ... is missing。它们看起来很吓人,但数量多、又不指向具体崩溃点,基本是噪音。
真正有区分力的,是重复出现的那两条:
Wwise: Cannot create streaming I/O device.
WwiseUnity: Failed to initialize the sound engine. Abort.
我把手上留存的崩溃报告做了分类:29 份里,28 份都走到了同一类 Wwise 音频工作线程的崩溃路径,剩下 1 份是之前试 DXVK 时的图形错误。
这里的转折点是:不要找"最显眼的报错",要找"每次都出现的报错"。 一个重复 28 次的现象,比一个新奇的一次性报错有价值得多。
⚠️ 顺便澄清一个坑:
streaming I/O device里的 I/O 是 Input/Output,指的是音频引擎内部读取声音数据的机制,不是设备管理器里那块物理声卡。如果一上来就理解成"声卡坏了",就会像六月一样围着音频驱动打转很久。
(另外要诚实:28/29 只是留存崩溃样本的分类,不是启动失败率——成功的启动不会留下崩溃报告。)
第 2 步:别轻信崩溃栈里给出的函数名
原始报告把出错的栈帧标成了 AK::StreamMgr::SetFileLocationResolver,看起来像"文件位置解析"出了问题。
但没有完整调试符号(PDB)时,工具只能拿邻近的导出符号名去猜地址。这个名字不能证明真正执行的就是那个函数。
我算了模块相对地址(RVA)再反汇编,发现真正值得注意的位置是:
AkSoundEngine.dll + 0x5B0C8
AkSoundEngine.dll + 0x5B16E
这两处紧跟在虚函数间接调用之后。而崩溃时线程想跳转的目标地址,根本不在正常模块的代码区里。
还有个细节:Unity 的文本报告写的是"写入冲突(Write to location)",但转储文件里 ExceptionInformation[0] 的值是 8。按微软文档,参数为 8 表示用户态数据执行保护(DEP)违规——也就是"试图执行一段不允许执行的内存"。
"写数据写到坏地址"和"跳到坏地址去执行"是完全两回事。前者像内存问题,后者更像对象已经失效、取到了一个错误的调用目标。
这里的转折点是:文本日志和二进制转储打架时,去看转储。
第 3 步:抓"真正失败的那个调用"
到这一步,我只知道"它跳错了地方",不知道"从哪一步开始错的"。
所以我写了个诊断脚本:让它在自己启动的游戏子进程里,在音频线程初始化附近下临时断点,记录参数、返回值,以及目标线程自己的错误码。
原始运行抓到了这样一组数据:
线程优先级: 0
线程核心掩码: 0xFFFFFFFC
线程栈大小: 128 KiB
SetThreadPriority 返回 1 → 成功
SetThreadAffinityMask 返回 0 → 失败
Windows 错误码 87 → ERROR_INVALID_PARAMETER
随后发生 0xC0000005 → 访问冲突
SetThreadAffinityMask 的作用是告诉系统"这个线程只准在指定的这些 CPU 核心上跑"。掩码里每一位代表一个逻辑处理器。
这一步补上的,是六月缺的那三个答案:哪个函数(第 2 步)、什么参数、(第 3 步)、什么返回值(第 3 步)。
第 4 步:把假设拿去做实验,而不是当成结论
有了错误码,自然就有了假设:游戏请求的掩码是 0xFFFFFFFC,而机器进程报告可用的只有 0xFFFFF——是不是请求了不允许的高位?
这个假设很有诱惑力,因为它能直接对上微软文档里"掩码包含不允许的位会返回 ERROR_INVALID_PARAMETER"。
但实验结果是:
| 实验 | 结果 |
|---|---|
把掩码从 0xFFFFFFFC 截断到 0xFFFFC |
仍然失败,还是错误 87 |
| 写个独立小程序,给自己的线程设同样的掩码 | 成功 |
所以,"高位越界"这个解释被我自己的实验否掉了。
这里是九月和六月最大的区别之一:六月的假设只要"下一次没崩"就能存活;九月的假设必须扛住反例。
到这里,我承认了一个还没解决的问题:宿主环境为什么偏偏拒绝这个游戏线程的绑定,我解释不了。 不是"核心太多",不是"CPU 坏了",也不是某个声卡驱动。
第 5 步:反汇编,看"失败之后"发生了什么
既然定位不到"为什么失败",就换个问题问:失败之后,程序做了什么?
反汇编这段逻辑之后,看到了一个要命的顺序:
thread = CreateThread(/* 创建后立即可运行 */);
if (!ConfigurePriorityAndAffinity(thread)) { // 这里失败了(错误 87)
CloseHandle(thread);
thread = nullptr; // 句柄被清空
return InitializationFailed;
}
// ……后续清理阶段
if (thread != nullptr) { // 句柄是空的,于是跳过等待
WaitForSingleObject(thread, INFINITE);
}
// 然后,销毁线程还在用的那些对象
关键点在于:关闭线程句柄 ≠ 结束线程。
句柄可以理解成"访问系统对象的一张票"。把票撕了,线程还在跑。微软的 CloseHandle 文档写得很清楚:关闭线程句柄不会终止对应线程。
于是整个链条闭合了:
sequenceDiagram
participant M as 初始化线程
participant W as 音频读取线程
M->>W: CreateThread,立即可运行
M->>M: SetThreadPriority 成功
M->>M: SetThreadAffinityMask 失败(错误 87)
M->>M: CloseHandle 并清空句柄
Note over M,W: 关掉句柄不会让线程停下来
M->>M: 初始化失败,进入清理流程
Note over M,W: 句柄没了,后面的等待被跳过
W->>W: 通过已经失效的对象做间接调用
W->>W: 目标地址不可执行 → 0xC0000005
这也顺带解释了那个"偶尔进得去但没声音"的诡异现象:线程调度和内存复用有时间差。初始化失败时,声音引擎处于未就绪状态→没声音;而那个还在跑的线程会不会马上撞上失效地址,要看时机——撞上就闪退,没撞上就放你进去,但依然是静音的。
一个机制,两种表现。到这一步,之前所有零散的现象第一次被同一套解释串起来了。
最后改动:8 个字节
既然触发点是"音频线程显式绑定核心失败",那修复思路就很自然:别让它显式绑定。
有意思的是,这个 DLL 自己就有这个分支——它的线程创建逻辑里,掩码为 0 时会直接跳过 SetThreadAffinityMask 调用,走正常的系统调度。
所以补丁不是被"发明"出来的,而是利用了程序里本来就存在的语义:
// 原来的导出函数(简化)
if (properties != nullptr)
properties->dwAffinityMask = suppliedMask;
// 补丁后
if (properties != nullptr)
properties->dwAffinityMask = 0; // 0 = 不显式绑定
这里的 0 不是"绑到零个核心",而是"DLL 看到 0 就不调用绑定 API"。
具体改动:
函数 RVA: 0x121F0
文件偏移: 0x115F0
原始:48 85 C9 74 03 89 51 04 C3 CC CC CC CC CC CC CC
补丁:48 85 C9 74 07 C7 41 04 00 00 00 00 C3 CC CC CC
替换区域 16 字节,实际变化 8 个字节。空指针检查保留,跳转距离相应调整,用的还是原函数尾部的填充空间——没有侵入后面那段代码。
九月的验证标准
这一步很重要,因为前面吃过"改完能进就算好了"的亏。所以这次定了更严的通过标准:
| 验证项 | 结果 |
|---|---|
| 原始 DLL 动态跟踪 | 绑定失败(错误 87),随后崩溃——复现了故障 |
| 只缩小掩码 | 仍然失败——否掉了错误假设 |
| 进程内取消显式绑定 | 通过音频初始化,进入后续场景 |
| 写入补丁后普通启动(无调试器、无运行时钩子) | 通过 |
| Windows 音频会话检查 | 活动、未静音、音量 100%,检测到非零音频电平(3 秒采样峰值 ≈ 0.01062) |
| 最后一次进程退出 | 我主动关的,不是闪退 |
| 安装 / 重复安装 / 还原 | 往返测试通过,还原后哈希与原件一致 |
两个细节值得单独说:
- "普通启动"是关键。 只在调试器下能跑通说明不了任何问题——普通启动也通过,才排除了"调试器掩盖了异常"。
- "非零音频电平"比"看到游戏画面"强得多。 检测只读取该游戏进程的音频会话状态和电平,不录音、不碰麦克风。它证明的是游戏确实往 Windows 音频管线里送了声音信号。
但我也必须说清楚这个补丁没有解决什么:
- 我没有搞清楚宿主机为什么拒绝这个游戏线程的绑定。这是绕过触发条件,不是根因修复。
- 没有完成长时间、全流程的游玩测试。
- 取消显式绑定可能改变性能特征(显式绑定时可能有它的性能目的)。
- 它不是"奥日 2 闪退通用补丁"。只对特定 DLL 构建的特定故障路径有效——安装器会校验完整 SHA-256,哈希对不上就拒绝执行。
四、两轮差在哪
4.1 一张对比表
| 六月 | 九月 | |
|---|---|---|
| 核心动作 | 试 | 测 |
| 故障描述 | "加载闪退、没声音" | 同一类 Wwise 线程崩溃,28/29 |
| 对日志的处理 | 看最显眼的报错 | 找重复出现的报错 |
| 对函数名的态度 | 直接采信文本栈 | 算 RVA 反汇编,自己确认 |
| 有没有抓到失败调用 | ❌ 没有 | ✅ 错误 87,参数、返回值齐全 |
| 假设怎么验证 | 改了之后能不能进 | 截断掩码仍失败 → 假设被否 |
| 失败路径 | 没往下看 | 反汇编看到句柄清空、等待被跳过 |
| 结论性质 | 猜的根因(虚拟扬声器) | 有对照支撑的兼容修复 |
| 变量控制 | 同时改好几项 | 一次只改一个 |
| 系统改动 | 刷了系统更新,机器刷崩过 | 全程没动系统 |
| 现象方向 | 六月就摸到了 Wwise(6/30) | 沿着它继续钻到调用层 |
注意表格倒数第二行和第一行的对比——六月其实站对了门口,只是没进去。
4.2 我的提问变了
这是我觉得两轮之间最实质的差别。
六月我问 AI 最多的句式是:"怎么办?" ——于是得到一张清单,然后逐个试。
但六月也不是没较真过,有一句我印象很深:
为啥是显卡驱动本身导致的崩溃?依据何在?
我当时已经在追问依据了。 可问题在于:那一轮里,"依据"这个词落不到实处——因为没有保存日志、没有控制变量、没有失败调用的返回值,所谓"依据"最后只能变成"我改了之后确实能进了"。
九月我问的句式变成了:
这里哪一步最早失败? 这个失败怎么验证? 改了之后,那个具体的失败调用消失了没有?
提问从"要办法"变成"要证据链",排查才开始收敛。
所以老实说,光是"问得更尖锐"还不够——六月我也问过"依据何在"。真正让这一轮收敛的是配套的做法:先存证据、一次改一个变量、用返回值而不是"这次能进"判定成败。
4.3 关于"AI 的进步"
如果要说这两轮之间有什么"进步",我更愿意把它准确地描述成排查方法的进步,而不是一句笼统的"AI 变强了":
- 六月的失败模式,其实是最典型的一种:把"相关性"当成了"因果"。 改一个设置,恰巧下一次没崩,于是宣布修复。这个陷阱跟工具无关,换谁来都容易掉进去。
- 九月之所以能走通,是因为换了问题。 不再是"哪个设置能修好它",而是"它在哪一步最早的失败、我怎么验证这一点"。问题一换,手段自然跟着变——动态调试、反汇编、对照实验,都是被这个问题逼出来的。
- 而方法一旦对了,AI 的价值确实不一样了。 六月它陪着我列清单、逐个试;九月它能配合我下断点读寄存器、算 RVA 反汇编、设计"截断掩码"这种专门用来否掉假设的对照实验,最后还帮着写了一个带哈希校验和还原功能的补丁安装器。
同一件事,六月做成了"穷举",九月做成了"收敛"。 差别不在候选办法的多少——六月办法更多——而在于有没有一套机制,把几十种可能性一路砍到剩下一条。
五、如果重来一次
这是这篇复盘真正想留下的东西。
1. 先存证据,再动手。
改任何设置之前,把日志复制出来。Unity 崩溃报告一般在 %LOCALAPPDATA%\Temp\Moon Studios\...\Crashes,常规日志在 %USERPROFILE%\AppData\LocalLow\Moon Studios\...。每次启动都会覆盖上一次的日志——这是我六月代价最大的地方。
2. 找"重复出现的错误",而不是"最吓人的错误"。 重复 28 次的报错,比一次性的显眼报错有价值得多。
3. 一次只改一个变量。 六月的失败不是"不够努力",是"变量太多"。改了驱动又改服务又改采样率,最后谁也说不清是哪一步起了作用——包括我自己。
4. 用返回值判断,别用"这次能进"判断。 本来就间歇性发生的故障,改完能进一次,等于什么都没证明。要看的是"那个具体的失败调用是否消失了"。
5. 相信转储,多于相信文本。 文本说"写入冲突",转储说"执行违规"——两条线索指向完全不同的方向。二进制不会为了可读性美化自己。
6. 反例出现时,立刻降低旧假设的权重。 换显卡还是崩、更新驱动没解决、进去之后依然无声——这些反例一出现,就该把"显卡是主因"的优先级往下压,而不是继续找理由维持它。
7. 系统级改动是最后手段,不是诊断工具。 为修一个游戏去刷系统更新,代价是机器开不了机、靠还原救回来。第一次严重失败后就该停下来,重新划定边界。
8. 允许自己承认"不知道"。 "为什么这个线程的绑定会失败"我至今没答上来。但知道"哪个调用失败了、绕开它之后是否恢复正常",已经足够修好问题。承认边界不丢人;把猜测写成结论,才会带着你在错的方向上跑三个月。
最后
这次排障最有价值的收获,不是那 8 个字节,而是把一个模糊的"加载闪退",一步步拆成了可以观测、可以对照、可以复现的东西:
哪个线程 → 哪个调用 → 什么参数 → 什么返回值 → 改动之后,在普通启动下是否依然成立。
六月和九月,看的是同一个故障,走的是两条路。
排障真正的困难,从来不是"办法不够多",而是判断哪一个是针对你这次故障的。
本文结论来自本机记录。文中 C++ 风格代码是为解释控制关系而写的伪代码,不是厂商源码。所有关键地址、参数、错误码和验证数据均可复核。