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

21 KiB
Raw Blame History

title, date, slug, description, tags, draft
title date slug description tags draft
《奥日 2》加载闪退排查记:六月靠猜,九月靠测 2026-09-23 ori-wotw-crash-troubleshooting-story 同一个闪退,我排查了两次。六月换显卡、禁声卡、刷系统更新,还把虚拟扬声器写成了根因;九月我换了套办法,最后改掉 8 个字节。这是一份两轮排查的对比复盘。
游戏排障
Windows
排查思路
逆向分析
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++ 风格代码是为解释控制关系而写的伪代码,不是厂商源码。所有关键地址、参数、错误码和验证数据均可复核。