Files
win-troubleshooting-notes-p…/ori-wotw-troubleshooting/文章/博客稿-奥日2排障记.md
T

411 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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++ 风格代码是为解释控制关系而写的伪代码,不是厂商源码。所有关键地址、参数、错误码和验证数据均可复核。*