最近用 Proton 跑一款日系視覺小說,畫面卡在開場 logo 動畫播完的那一瞬間就閃退,連標題畫面都進不去。最後我把它一路追到遊戲引擎裡一條少寫了一個 null 檢查的程式碼,並用一段 18 bytes 的修補碼把它補好,順手保留了開場動畫。

這篇把整個過程完整記錄下來:怎麼從一份吵雜的 Wine log 定位崩潰點、為什麼「刪掉影片檔」能繞過、反組譯出錯函式看懂它在做什麼,以及最後怎麼做出一份「對齊引擎自身慣例」的二進位 patch。組合語言的部分都附上可能對應的 C/C++ 還原碼,方便理解。

分工說明(按事實): 問題發現、環境重現與全部實機驗證(確認刪 logo.wmv 可繞過、套用 patch 後實測動畫正常播完並進入目錄)由 LFsWang 完成;log 分析、PAL.dll 反組譯、bug 成因定位、修補方案設計與修補程式撰寫由 Claude(Anthropic, Opus 4.8) 完成。本文由 Claude 主筆。

#零、緣起:我的 AI 協作思路(by LFsWang)

這個遊戲算是我在亂翻買來放了很久沒玩的遊戲時,發現「能用 Proton 開起來」的其中一款,可惜開到一半就掛掉。在跟 Windows 上的行為比對後,我確認它是死在進入遊戲目錄前的那一刻,覺得這有修復價值,於是動手開修。

生成 Log 之後,憑以前的經驗我就知道那 Log 超級醜,自己一行行看很花時間,所以我一開始就借助 AI 來分析。AI 很快給出跟影片有關的錯誤這個結論,老實說當下我有點翻白眼,因為影片出錯是 VN 遊戲的老毛病之一,很討厭。

不過我在實機驗證後發現一件關鍵的事:logo 影片其實是「正常播放完」才閃退的。這代表問題不太像是載入解碼器的文題,而比較像是異常處理的狀況。

所以除了 AI 一開始建議的「移除影片」這種繞法之外,我進一步給出指示:設法劫持原本程式的錯誤處理環節。順著這個方向,AI 才真正把這個 Bug 反組譯定位出來,發現是事件處理迴圈少了一個 null 檢查。最後,我再指示 AI 把所有牽涉到組合語言的段落,還原成適當的 C/C++ 程式碼,好讓這份紀錄留存得更清晰、更好讀。

下面就是這趟協作的完整技術過程。

#一、現象

  • 遊戲:時計仕掛けのレイライン ―黄昏時の境界線―(PAL 引擎的視覺小說)
  • 啟動器:Steam + GE-Proton10-32
  • 症狀:開場 logo 動畫可以正常播放,但播到結束的瞬間整個遊戲就崩潰,進不了標題目錄。

手上唯一的線索是 Proton 吐出來的 steam-11364325650971230208.log,5600 多行,絕大多數是 trace:unwind 之類的雜訊。

#二、在 log 裡找到崩潰點

先把真正的錯誤從雜訊裡撈出來。略過所有 trace: 行,只看 err: 跟例外,很快就找到關鍵段落:

1
2
3
4
5
6
7
8
95372.061  EC_COMPLETE                                          ← DirectShow:影片播放完畢
95372.077 EXCEPTION: System.ComponentModel.Win32Exception:
ウィンドウ ハンドルが正しくありません。 ← 視窗 handle 已失效
95372.098 backtrace: --- Exception 0xc0000005.
95372.098 code=c0000005 (EXCEPTION_ACCESS_VIOLATION) addr=100159D6
95372.098 info[0]=00000000 info[1]=00000000 ← 讀取位址 0x00000000
95372.098 eip=100159d6 eax=00000000 edi=00000000 esi=013c3850
wine: Unhandled page fault on read access to 00000000 at address 100159D6

三件事一目了然:

  1. 這是一個 c0000005 Access Violation,而且是讀取 0x00000000——典型的空指標解參考。
  2. 出錯位址 eip = 0x100159D6,崩潰當下 eax = 0
  3. 崩潰緊接在 EC_COMPLETE 之後——也就是 DirectShow 通知「影片放完了」的那一刻。

換句話說,你看到的「進目錄前崩潰」,其實是卡在開場動畫結束的瞬間

#三、崩潰的是誰?—— 定位模組

0x100159D6 這個位址屬於哪個模組?在 log 裡搜模組載入紀錄:

1
Loaded "...\dll\PAL.dll" at 10000000: native

PAL.dll 載入在基底 0x10000000,所以崩潰點的 RVA = 0x100159D6 − 0x10000000 = 0x159D6就在遊戲引擎自己的程式碼裡,不是某個 Windows API。

再往前看播放是怎麼搭起來的:

1
2
3
Loaded quartz.dll          ← DirectShow 核心
Loaded wmvcore.dll ← WMV 解碼
Loaded winegstreamer.dll ← Wine 用 GStreamer 接 DirectShow

所以開場動畫是一支 WMVlogo.wmv),透過 DirectShow 播放,而 Wine 底層用 GStreamer 去實作 DirectShow。播放本身沒問題(影片確實放完了),出事的是播完之後引擎自己的收尾程式碼。

#四、為什麼「刪掉 logo.wmv」就好了?

實測把 logo.wmv 移除,遊戲就能正常進目錄。原因很單純,也很重要:

問題從來不是影片,而是「影片播完之後」那段收尾邏輯。

PAL 引擎在播放前會先確認影片檔在不在;檔案不存在,它就整段開場動畫流程直接跳過,那條會崩潰的收尾路徑根本不會被執行。所以刪檔不是修好了 bug,而是讓有 bug 的程式碼沒機會跑。這是完全可行的繞法,代價只是少看一段 logo。

但我想知道為什麼會崩,而且如果可以的話,想保留動畫。於是把 PAL.dll 拆開來看。

#五、反組譯出錯函式

PAL.dll 是一個 32 位元 PE(PE32 / Intel i386),ImageBase 剛好就是 0x10000000,所以 RVA 跟執行期位址一致。把 0x159D6 周邊反組譯出來,函式入口在 0x100159C0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
100159c0  push  ebp
100159c1 mov ebp, esp
100159c3 sub esp, 0Ch ; 三個區域變數
100159c6 push esi
100159c7 push 0
100159c9 lea edx, [ebp-8]
100159cc push edx ; &lParam2
100159cd lea edx, [ebp-0Ch]
100159d0 push edx ; &lParam1
100159d1 mov esi, eax ; esi = this(崩潰時 esi = 0x013C3850)
100159d3 mov eax, [esi+8] ; eax = this->m_pMediaEvent
100159d6 mov ecx, [eax] ; *** 崩潰:eax==0,讀 NULL 的 vtable ***
100159d8 lea edx, [ebp-4]
100159db push edx ; &evCode
100159dc push eax ; this(IMediaEvent)
100159dd mov eax, [ecx+20h] ; vtable[8] = IMediaEvent::GetEvent
100159e0 call eax
100159e2 test eax, eax
100159e4 js 10015a45 ; FAILED → 安全收尾
...
100159f1 mov eax, [esi+8]
100159f4 mov ecx, [eax]
... ; 取 vtable[0x30] = FreeEventParams 並呼叫
10015a05 mov eax, [ebp-4] ; evCode
10015a08 cmp eax, 1 → je ... ; EC_COMPLETE
10015a0d cmp eax, 2 → je ... ; EC_USERABORT
10015a12 cmp eax, 3 → jne ... ; EC_ERRORABORT

那串 cmp eax, 1 / 2 / 3 是決定性線索——它正好是 DirectShow 的事件碼:

常數 意義
1 EC_COMPLETE 播放完成
2 EC_USERABORT 使用者中止
3 EC_ERRORABORT 錯誤中止

加上它呼叫 GetEvent(vtable offset 0x20)跟 FreeEventParams(offset 0x30),可以很有把握地判斷:這個函式就是 DirectShow 的「事件泵」(event pump)——一個迴圈,不斷把影片播放過程中產生的事件抽出來處理。

還原成 C++ 大概長這樣(示意,非原始碼):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// sub_100159C0 — this 透過 eax 傳入(暫存器呼叫慣例)
void CMoviePlayer::PumpEvents()
{
long evCode;
LONG_PTR p1, p2;

// ↓↓↓ BUG:這裡直接用 m_pMediaEvent,沒有檢查它是不是 NULL ↓↓↓
while (SUCCEEDED(m_pMediaEvent->GetEvent(&evCode, &p1, &p2, 0)))
{
OnEvent(evCode); // sub_10014AF0
m_pMediaEvent->FreeEventParams(evCode, p1, p2);

if (evCode == EC_COMPLETE || evCode == EC_USERABORT) {
Stop(0); // 0x10015A52:乾淨收尾後 return
return;
}
if (evCode == EC_ERRORABORT) {
Stop(1); // sub_10015820(1)
OnError(); // sub_10015B20
}
}
}

m_pMediaEvent 就是物件偏移 +8 的那個 IMediaEvent 介面指標。崩潰時 eax = [esi+8] = 0,接著 mov ecx,[eax] 去讀 NULL 的 vtable,當場 Access Violation。

#六、Bug 的本質:唯一漏掉的 null check

問題是:m_pMediaEvent+8)為什麼在這個時間點會是 NULL?

把旁邊的拆除函式(teardown,結束於 0x100159B2)也反組譯出來,真相就清楚了。它負責在影片結束後釋放所有 DirectShow 介面:

1
2
3
4
5
6
7
10015919  mov   eax, [esi+8]        ; m_pMediaEvent
1001591c cmp eax, edi ; edi = 0
1001591e je 1001592b ; 若為 NULL 就跳過
10015920 mov ecx, [eax]
10015922 mov edx, [ecx+8] ; vtable[2] = IUnknown::Release
10015926 call edx
10015928 mov [esi+8], edi ; m_pMediaEvent = NULL ←★ 釋放後寫回 0

還原成 C++:

1
2
3
4
5
6
7
8
void CMoviePlayer::Teardown()
{
// 注意:每一個介面在釋放前都有先做 null 檢查
if (m_pBasicVideo) { m_pBasicVideo->Release(); m_pBasicVideo = nullptr; } // [esi+0x14]
if (m_pMediaEvent) { m_pMediaEvent->Release(); m_pMediaEvent = nullptr; } // [esi+0x08]
if (m_pGraph) { m_pGraph->Release(); m_pGraph = nullptr; } // [esi+0x00]
// ... 其餘介面同樣的 if (p) { p->Release(); p = nullptr; } 模式
}

於是事情串起來了:

  1. 影片播完,DirectShow 發出 EC_COMPLETE
  2. 引擎呼叫 Teardown(),把 m_pMediaEvent Release 並設成 NULL
  3. 事件泵 PumpEvents() 又被呼叫了一次(或迴圈多跑了一圈),這次 m_pMediaEvent 已經是 NULL。
  4. PumpEvents() 偏偏沒有 null 檢查,直接 m_pMediaEvent->GetEvent(...) → 讀 NULL vtable → c0000005

關鍵證據是這個對比:

PAL.dll 裡每一個用到這些介面的地方——Teardown()、另一個 0x10015A70 的取狀態函式——全都有先 cmp …, 0 / je 做空值檢查;唯獨事件泵 PumpEvents() 漏了。

這是一個典型的 use-after-null / 競態時序 bug。在真正的 Windows 上,DirectShow 的事件投遞和介面釋放的時序剛好錯開,PumpEvents() 永遠不會在 m_pMediaEvent 被清空後又被呼叫,所以原廠從沒踩到。換到 Wine/GStreamer 實作的 DirectShow,時序不同,就穩定地踩中了這個本來就存在的漏洞。

log 裡崩潰前一行那個 ウィンドウ ハンドルが正しくありません(視窗 handle 無效)也呼應了同一件事——影片視窗在收尾階段已經失效,整個 graph 正在被拆掉。

#七、修法 A:靜態二進位修補

既然 bug 就是「少一個 null check」,最乾淨的修法就是把它補回去

#借用引擎自己的安全出口

先看那個 js 10015a45(GetEvent 失敗時走的路)跳到哪:

1
2
3
4
5
6
10015a45  push  1
10015a47 call ds:[100db0a0] ; 既有的清理
10015a4d pop esi
10015a4e mov esp, ebp ; ← 直接用 ebp 還原堆疊
10015a50 pop ebp
10015a51 ret

這段是引擎自己的安全出口:它用 mov esp, ebp 把堆疊一次還原乾淨,所以不管之前往堆疊推了幾個東西都沒關係。這代表只要在踩到 NULL 之前判斷出 m_pMediaEvent == NULL,直接跳到 0x10015A45,就等同於「GetEvent 失敗」那條已經存在、且驗證過安全的路徑。完全不必自己做堆疊手術。

#找一塊 code cave

編譯器在函式之間留了一堆 int30xCC)對齊填充。掃一遍 .text,找到好幾塊夠大的空白,挑了 0x100BEBD5(43 bytes)來放修補碼。

#Detour + 補丁

出錯前的兩個指令剛好 5 bytes,正好換成一個 jmp

1
2
原始 @ 0x100159D3:  8B 46 08 8B 08          mov eax,[esi+8] ; mov ecx,[eax]
改成 @ 0x100159D3: E9 FD 91 0A 00 jmp 0x100BEBD5 ← 跳到 cave

cave 裡補上那個缺失的 null 檢查:

1
2
3
4
5
6
; @ 0x100BEBD5
100bebd5 mov eax, [esi+8] ; 重新載入 m_pMediaEvent
100bebd8 test eax, eax
100bebda je 10015a45 ; == NULL → 跳引擎安全出口(等同 GetEvent 失敗)
100bebe0 mov ecx, [eax] ; != NULL → 原本的動作
100bebe2 jmp 100159d8 ; 回到正常流程

對應的 C++ 就是把那行迴圈條件補上守衛:

1
2
3
4
5
6
7
8
9
10
void CMoviePlayer::PumpEvents()
{
long evCode; LONG_PTR p1, p2;
while (m_pMediaEvent /* ← 補上的檢查 */ &&
SUCCEEDED(m_pMediaEvent->GetEvent(&evCode, &p1, &p2, 0)))
{
...
}
// m_pMediaEvent 為 NULL → 直接走 0x10015A45 乾淨返回
}

整份修補只動了 5 個 byte,外加一段原本是 int3 的空白區,沒有覆蓋任何既有指令。改完用反組譯把三處(detour、cave、出口)都核對過,確認跳轉與位址都正確:

1
2
3
4
5
6
100159d3:  e9 fd 91 0a 00     jmp    0x100bebd5        ✓
100bebd5: 8b 46 08 mov eax,[esi+0x8]
100bebd8: 85 c0 test eax,eax
100bebda: 0f 84 65 6e f5 ff je 0x10015a45 ✓
100bebe0: 8b 08 mov ecx,[eax]
100bebe2: e9 f1 6d f5 ff jmp 0x100159d8 ✓

#修補程式

把上面的步驟寫成一支 Python 腳本:它自己解析 PE 標頭、把 RVA 換算成檔案 offset、組出 cave 裡的修補碼、確認 cave 真的是空白(全 0xCC)才寫入,最後把 detour 蓋上去。所有位址、rel32 位移都用程式算,避免手算出錯。原檔不動,輸出到一份新的 DLL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#!/usr/bin/env python3
import sys, struct

src, out = sys.argv[1], sys.argv[2]
d = bytearray(open(src, 'rb').read())

IMG = 0x10000000 # ImageBase(也是執行期載入位址)
SITE = 0x100159D3 # mov eax,[esi+8] ; mov ecx,[eax] (要被換掉的 5 bytes)
BACK = 0x100159D8 # lea edx,[ebp-4] 正常流程的接續點
EXIT = 0x10015A45 # 引擎自己的安全出口(mov esp,ebp ; pop ebp ; ret)
CAVE = 0x100BEBD5 # 一塊 43 bytes 的 int3 code cave

# .text: VirtualAddress 0x1000, Raw 0x400
def va2off(va):
return (va - IMG - 0x1000) + 0x400

def rel32(end, target): # E8/E9/0F8x 的相對位移以「指令結尾」為基準
return (target - end) & 0xFFFFFFFF

# --- 組出 cave 裡的修補碼 ---
cave = bytearray()
cave += bytes([0x8B, 0x46, 0x08]) # mov eax,[esi+8] 重新載入 m_pMediaEvent
cave += bytes([0x85, 0xC0]) # test eax,eax
je_at = CAVE + len(cave) # je EXIT(near, 6 bytes)
cave += bytes([0x0F, 0x84]) + struct.pack('<I', rel32(je_at + 6, EXIT))
cave += bytes([0x8B, 0x08]) # mov ecx,[eax] 原本那條,現在有守衛了
jmp_at = CAVE + len(cave) # jmp BACK(near, 5 bytes)
cave += bytes([0xE9]) + struct.pack('<I', rel32(jmp_at + 5, BACK))

# --- 寫入 cave(先確認整塊都是 0xCC,不會蓋到任何程式碼)---
coff = va2off(CAVE)
assert all(b == 0xCC for b in d[coff:coff + len(cave)]), "cave not free!"
d[coff:coff + len(cave)] = cave

# --- 在 SITE 蓋上 detour:jmp CAVE(剛好 5 bytes,等於原本兩條指令的長度)---
soff = va2off(SITE)
orig = bytes(d[soff:soff + 5])
det = bytes([0xE9]) + struct.pack('<I', rel32(SITE + 5, CAVE))
d[soff:soff + 5] = det

open(out, 'wb').write(d)
print("patched ->", out)
print("site:", orig.hex(), "->", det.hex()) # 8b46088b08 -> e9fd910a00
print("cave:", cave.hex()) # 8b460885c00f84656ef5ff8b08e9f16df5ff

執行並用 objdump 反組譯驗證三個點:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ python3 patch_pal.py PAL.dll PAL.patched.dll
patched -> PAL.patched.dll
site: 8b46088b08 -> e9fd910a00
cave: 8b460885c00f84656ef5ff8b08e9f16df5ff

# detour 點
$ objdump -d -M intel --start-address=0x100159d1 --stop-address=0x100159e0 PAL.patched.dll
100159d1: 8b f0 mov esi,eax
100159d3: e9 fd 91 0a 00 jmp 0x100bebd5 ✓
100159d8: 8d 55 fc lea edx,[ebp-0x4]

# cave 內容
$ objdump -d -M intel --start-address=0x100bebd5 --stop-address=0x100bebe7 PAL.patched.dll
100bebd5: 8b 46 08 mov eax,[esi+0x8]
100bebd8: 85 c0 test eax,eax
100bebda: 0f 84 65 6e f5 ff je 0x10015a45 ✓
100bebe0: 8b 08 mov ecx,[eax]
100bebe2: e9 f1 6d f5 ff jmp 0x100159d8 ✓

把 logo.wmv 放回去、換上修補後的 PAL.dll,開場動畫正常播完、順利進入標題目錄,不再閃退。bug 真正被修好了,而不是被繞過。

小提醒:替換前先備份原始 PAL.dll。這份 patch 綁特定版本的 DLL(視覺小說通常沒有完整性檢查,所以沒問題)。

#八、修法 B:VEH(不改檔的執行期 hook)

如果不想動原檔,可以用等價的執行期手法:做一個 32 位元 proxy DLL(例如 version.dllwinmm.dll,丟到遊戲 exe 同目錄,Wine 會優先載本地的),在 DllMain 裡裝一個 Vectored Exception Handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
static BYTE *base;  // = (BYTE*)GetModuleHandleA("PAL.dll");

static LONG CALLBACK Veh(EXCEPTION_POINTERS *e)
{
if (e->ExceptionRecord->ExceptionCode == EXCEPTION_ACCESS_VIOLATION &&
e->ContextRecord->Eip == (DWORD)(base + 0x159D6))
{
e->ContextRecord->Eip = (DWORD)(base + 0x15A45); // 跳到引擎安全出口
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}
// DllMain(DLL_PROCESS_ATTACH): AddVectoredExceptionHandler(1, &Veh);

原理跟靜態 patch 一模一樣:攔到 0x159D6 的 Access Violation,就把 Eip 改到 0x15A45。因為那個出口會自己 mov esp, ebp 還原堆疊,所以零風險、不需要任何堆疊處理。

  • 優點:不改原檔;跨版本只要調整 offset。
  • 缺點:要額外編一個 proxy DLL。

#九、結語

回頭看,這個 bug 的因果鏈其實很乾淨:

  1. Wine 的 DirectShow 時序與 Windows 不同,讓事件泵在介面被釋放後又跑了一次。
  2. PAL 引擎漏寫了一個 null 檢查——它在別處每次都檢查,唯獨這裡沒有。
  3. 兩者相撞 → 空指標解參考 → 閃退。

「刪掉 logo.wmv」是最省事的繞法;但真正的修法是補回那個缺失的守衛,方式可以是 18 bytes 的靜態 patch,也可以是一個把 Eip 導向引擎自身安全出口的 VEH。最讓我滿意的一點是:修補完全沿用引擎自己的收尾路徑,沒有自創任何清理邏輯,所以行為跟「影片正常播完」完全一致。

從一份吵雜的 log,到看懂一個十幾年前編譯出來的函式在做什麼,再用幾個 byte 把它補好——逆向的樂趣大概就在這裡。


分工:問題發現、環境重現與實機驗證 — LFsWang;log 分析、PAL.dll 反組譯、bug 定位、修補設計與本文撰寫 — Claude(Anthropic, Opus 4.8)。