新增奥日2排障记录:Wwise 线程亲和性故障定位与兼容补丁(文章4篇+补丁包+诊断脚本4个,已脱敏)

This commit is contained in:
SCHOOL
2026-09-23 15:08:27 +08:00
parent 90bcf6176e
commit 6bf9981765
21 changed files with 2815 additions and 0 deletions
@@ -0,0 +1,410 @@
---
title: "《奥日 2》加载闪退排查记:六月靠猜,九月靠测"
date: 2026-09-23
slug: ori-wotw-crash-troubleshooting-story
description: "同一个闪退,我排查了两次。六月换显卡、禁声卡、刷系统更新,还把虚拟扬声器写成了根因;九月我换了套办法,最后改掉 8 个字节。这是一份两轮排查的对比复盘。"
tags:
- 游戏排障
- Windows
- 排查思路
- 逆向分析
draft: 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`。它们看起来很吓人,但数量多、又不指向具体崩溃点,**基本是噪音**。
真正有区分力的,是**重复出现**的那两条:
```text
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)再反汇编,发现真正值得注意的位置是:
```text
AkSoundEngine.dll + 0x5B0C8
AkSoundEngine.dll + 0x5B16E
```
这两处**紧跟在虚函数间接调用之后**。而崩溃时线程想跳转的目标地址,**根本不在正常模块的代码区里**。
还有个细节:Unity 的文本报告写的是"写入冲突(Write to location)",但转储文件里 `ExceptionInformation[0]` 的值是 **8**。按微软文档,参数为 8 表示**用户态数据执行保护(DEP)违规**——也就是"试图执行一段不允许执行的内存"。
"写数据写到坏地址"和"跳到坏地址去执行"是完全两回事。前者像内存问题,后者更像**对象已经失效、取到了一个错误的调用目标**。
**这里的转折点是:文本日志和二进制转储打架时,去看转储。**
### 第 3 步:抓"真正失败的那个调用"
到这一步,我只知道"它跳错了地方",不知道"从哪一步开始错的"。
所以我写了个诊断脚本:让它在自己启动的游戏子进程里,在音频线程初始化附近下临时断点,**记录参数、返回值,以及目标线程自己的错误码**。
原始运行抓到了这样一组数据:
```text
线程优先级: 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 步:反汇编,看"失败之后"发生了什么
既然定位不到"为什么失败",就换个问题问:**失败之后,程序做了什么?**
反汇编这段逻辑之后,看到了一个要命的顺序:
```cpp
thread = CreateThread(/* 创建后立即可运行 */);
if (!ConfigurePriorityAndAffinity(thread)) { // 这里失败了(错误 87)
CloseHandle(thread);
thread = nullptr; // 句柄被清空
return InitializationFailed;
}
// ……后续清理阶段
if (thread != nullptr) { // 句柄是空的,于是跳过等待
WaitForSingleObject(thread, INFINITE);
}
// 然后,销毁线程还在用的那些对象
```
关键点在于:**关闭线程句柄 ≠ 结束线程。**
句柄可以理解成"访问系统对象的一张票"。把票撕了,线程还在跑。微软的 `CloseHandle` 文档写得很清楚:关闭线程句柄**不会**终止对应线程。
于是整个链条闭合了:
```mermaid
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` 调用,走正常的系统调度。
所以补丁不是被"发明"出来的,而是**利用了程序里本来就存在的语义**:
```cpp
// 原来的导出函数(简化)
if (properties != nullptr)
properties->dwAffinityMask = suppliedMask;
// 补丁后
if (properties != nullptr)
properties->dwAffinityMask = 0; // 0 = 不显式绑定
```
这里的 `0` **不是"绑到零个核心"**,而是"DLL 看到 0 就不调用绑定 API"。
具体改动:
```text
函数 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++ 风格代码是为解释控制关系而写的伪代码,不是厂商源码。所有关键地址、参数、错误码和验证数据均可复核。*