最受歡迎的作業系統內建遊戲之一,無疑是 彈珠台,完整名稱為 3D Pinball for Windows – Space Cadet。它最早以 Full Tilt! Pinball 之名,由 Cinematronics 開發並由 Maxis 發行,提供了 3 個桌台,其中一個 Space Cadet 被授權給微軟,收錄於 Microsoft Plus! 95,之後更內建於 Windows 作業系統中。

Windows XP 是最後一個內建彈珠台的 Windows 版本,Raymond Chen 曾在 他的部落格 解釋為什麼它沒有進入 Windows Vista。原因是當它被編譯為 64 位元 Windows 版本時,碰撞偵測器出現錯誤,導致球會穿過各種物件——例如,球會直接從發射器掉出螢幕,而不是被發射出去。這個錯誤讓遊戲無法遊玩,而 Raymond 和他的同事在合理時間內無法找到修復方法,因此他移除了它。至少這是我們被告訴的故事,大約持續了十年。
2021 年,NCommander 發起 一系列調查 來挑戰這個說法,他在各種 64 位元(IA-64 和 AMD64)Windows XP 及 Vista 預覽版本上測試彈珠台。他發現 64 位元版本的彈珠台都相當好玩,只有極小的故障,並推測它被移除的原因是使用者介面不符合 Windows Vista 的設計。
在 NCommander 發表影片後不久,Raymond 跟進發表了一篇 文章,補充了一些故事的細節,並對這個錯誤提供了更多說明。他表示,彈珠台的 64 位元 Alpha AXP 版本才有極嚴重的碰撞偵測錯誤。這個說法在過去 5 年內無法被驗證,原因如下:
- Alpha AXP 從未發行過任何 64 位元 Windows——Compaq 在 NT 被移植到 64 位元之前就終止了對 Windows NT 的支援
- 2023 年曾洩露一份 64 位元 Alpha AXP NT 組建,但內建的彈珠台無法執行,啟動後立即發生記憶體區段錯誤
我對 DEC Alpha 一直很有興趣,主要是出於對 DEC 架構和 UNIX 的熱愛。VAX 是 PDP-11 的直接後繼者,而 Alpha 又是 VAX 的直接後繼者。早先,Alpha 模擬器出現了一些突破性進展,有幾個朋友提醒我,NT 4.0 現在可以在 ES40 模擬器的分支 以及 QEMU 上執行。我從來沒有想過 Alpha NT 能在模擬器上執行,因為不同於熟悉的 Tru64、Linux 和 BSD,NT 使用自己專屬的 PALcode,並依賴 ARC(Advanced RISC Computing)而非 SRM。當然,有人指出這些模擬器無法執行 Alpha NT 的聖杯——Windows(XP?)組建 2210,因為它的核心會在 QEMU 中因記憶體管理錯誤而當機,或在 ES40 中無法偵測鍵盤而發生問題。經過在沒有符號的 NT 核心中多次「地獄之旅」以及一些 MMU 模擬修正後,我成功修補了 QEMU 和 ES40,讓它們都能啟動這份僅存的 64 位元 Alpha NT 組建。

在沒有核心偵錯器的情況下,辛苦除錯沒有符號的 NT 核心後,我想嘗試修復彈珠台,讓這一切更有價值。使用者層級程序除錯的一個好處是,雖然還是沒有偵錯器,但有 Dr. Watson,它可以取得核心傾印並進行簡單的事後分析。有總比沒有好,如人們所說。
執行彈珠台後,立即出現經典的當機症狀,沒有任何圖形被繪製:

Dr. Watson 判斷它死於記憶體區段錯誤:

它提供了發生錯誤時的暫存器傾印:
State Dump for Thread Id 0x124
v0=01002930 00000000 t0=00000000 00360000 t1=00000000 00000001
t2=00000000 00360000 t3=00000000 00000000 t4=00000000 00000000
t5=00000000 0000011c t6=000003ff fff8f868 t7=00000000 00303030
s0=000003ff fff8fac0 s1=01002930 00000000 s2=000003ff fff8fad8
s3=00000000 00000000 s4=00000000 0106f2a8 s5=00000000 01000000
fp=00000000 00000010 a0=01002930 00000000 a1=00000000 00000000
a2=000003ff fff8fad8 a3=00000000 30010000 a4=00000000 69e17610
a5=00000000 69e0a360 t8=000003ff fff8f868 t9=00000000 00000000
t10=00000000 00300000 t11=00000000 00000002 ra=00000000 69e9d5c0
t12=00000000 6a264710 at=ffffffff fffffe10 gp=00000000 00000000
sp=000003ff fff8fa50 zero=00000000 00000000 fpcr=08000000 00000000
SoftFpcr=00000000 00000000 fir=6a264710
psr=00000003
mode=1 ie=1 irql=0
錯誤指令周圍的一些反組譯碼:
function: Otsstrlen
FAULT ->00000000'6a264710: 2f700000 ldq_u t12,0(a0)
00000000'6a264714: 239fffff lda at,-1(zero)
00000000'6a264718: 4b90065c mskql at,a0,at
00000000'6a26471c: 4600f000 and a0,#7,v0
00000000'6a264720: 477c041b bis t12,at,t12
00000000'6a264724: 43fb01fb cmpbge zero,t12,t12
00000000'6a264728: 43e00520 subq zero,v0,v0
00000000'6a26472c: f7600005 bne t12,00000000'6a264744 Otsstrlen+00000034
00000000'6a264730: 2f700008 ldq_u t12,8(a0)
00000000'6a264734: 42011410 addq a0,#8,a0
00000000'6a264738: 40011400 addq v0,#8,v0
00000000'6a26473c: 43fb01fb cmpbge zero,t12,t12
以及非常有用的堆疊回溯:
*----> Stack Back Trace <----*
FramePtr ReturnAd Param#1 Param#2 Param#3 Param#4 Function Name
000003FFFFF8FA50 0000000069E9D5BC 0100293000000000 0000000000000000 000003FFFFF8FAD8 0000000030010000 !Otsstrlen
000003FFFFF8FA50 0000000069E9DB64 0100293000000000 0000000000000000 000003FFFFF8FAD8 0000000030010000 !PostThreadMessageA
000003FFFFF8FA90 0000000069E9C000 0100293000000000 0000000000000000 000003FFFFF8FAD8 0000000030010000 !SetClassLongA
000003FFFFF8FB80 000000000100F914 000003FFFFF8FC58 0000000000000000 000003FFFFF8FAD8 0000000030010000 !RegisterClassA
000003FFFFF8FBF0 0000000001012A1C 0000000001000000 0000000001002DC8 000003FFFFF8FAD8 0000000030010000 !<nosymbols>
000003FFFFF8FCA0 0000000001064B0C 0000000001000000 0000000001002DC8 000003FFFFF8FAD8 0000000030010000 !<nosymbols>
000003FFFFF8FED0 0000000068948C50 0000000001000000 0000000001002DC8 000003FFFFF8FAD8 0000000030010000 !<nosymbols>
000003FFFFF8FFC0 0000000000000000 0000000001064800 0000000001002DC8 000003FFFFF8FAD8 0000000030010000 !BaseProcessStart
好的,所以它在 RegisterClassA 內部當機,這是一個關鍵的 Win32 API 函式。這個 API 函式不可能是罪魁禍首,因為如果它有錯誤,任何 GUI Win32 程式都無法執行。這意味著唯一的錯誤來源是它的唯一參數——指向 WNDCLASSA 結構的指標。不用說,這個指標本身是有效的,否則 API 會偵測到無效參數,或者記憶體區段錯誤會發生得更早。
從堆疊追蹤來看,RegisterClassA 的返回位址是 0x100F914,位於 splash_screen 函式內。快速反組譯該位址之前的指令,顯示正在建立一個具有以下配置的 WNDCLASSA 結構:
00000000 u32 style = 0
00000004 u64 lpfnWndProc = splash_message_handler (0x100FE40)
0000000C u32 cbClsExtra = 0
00000010 u32 cbWndExtra = 8
00000014 u64 hInstance = *0x106AE30
0000001C u64 hIcon = NULL
00000024 u64 hCursor = LoadCursorA(NULL, IDC_ARROW)
0000002C u64 hbrBackground = NULL
00000034 u64 lpszMenuName = "" (0x1002710)
0000003C u64 lpszClassName = "3DPB_SPLASH_CLASS" (0x1002930)
我馬上注意到有問題——欄位對齊。這是一般要求,欄位必須對齊到其大小,例如 8 位元欄位應以位元組對齊,16 位元欄位應以 16 位元(2 位元組)對齊,32 位元欄位應以 32 位元(4 位元組)對齊,64 位元欄位應以 64 位元(8 位元組)對齊。如果你查看上述欄位的偏移量,32 位元欄位確實是 4 位元組對齊,但 64 位元欄位卻不是。一開始,我們有一個 32 位元 style 欄位,後面跟著一個 64 位元 lpfnWndProc,為了滿足對齊要求,應該在 style 和 lpfnWndProc 之間插入 4 位元組的填充,以確保 lpfnWndProc 從 8 位元組邊界開始。RegisterClassA 期望有這個填充,但 Pinball 缺少它,所以它從錯誤的偏移量讀取資料並當機。
為了修復這個問題,我只需將 style 之後每個欄位的偏移量增加 4 位元組。
00000000 u32 style = 0
-00000004 u64 lpfnWndProc = splash_message_handler (0x100FE40)
+00000008 u64 lpfnWndProc = splash_message_handler (0x100FE40)
-0000000C u32 cbClsExtra = 0
+00000010 u32 cbClsExtra = 0
-00000010 u32 cbWndExtra = 8
+00000014 u32 cbWndExtra = 8
-00000014 u64 hInstance = *0x106AE30
+00000018 u64 hInstance = *0x106AE30
-0000001C u64 hIcon = NULL
+00000020 u64 hIcon = NULL
-00000024 u64 hCursor = LoadCursorA(NULL, IDC_ARROW)
+00000028 u64 hCursor = LoadCursorA(NULL, IDC_ARROW)
-0000002C u64 hbrBackground = NULL
+00000030 u64 hbrBackground = NULL
-00000034 u64 lpszMenuName = "" (0x1002710)
+00000038 u64 lpszMenuName = "" (0x1002710)
-0000003C u64 lpszClassName = "3DPB_SPLASH_CLASS" (0x1002930)
+00000040 u64 lpszClassName = "3DPB_SPLASH_CLASS" (0x1002930)
但這還不夠——Pinball 在 4 個不同的地方呼叫 RegisterClassA——Sound_Init、splash_screen、WinMain 和 WaveMixStartup。我已經修補了 splash_screen 中的那個,所以我開始逐一檢查其餘的。
Sound_Init 和 WinMain 中的那個與 splash_screen 中的相同,但出於某種奇怪的原因,WaveMixStartup 中的那個已經有正確的對齊:
00000000 u32 style = 0
00000004 u32 <unused> = <undefined>
00000008 u64 lpfnWndProc = WndProc (0x105CFA0)
00000010 u32 cbClsExtra = 0
00000014 u32 cbWndExtra = 0
00000018 u64 hInstance = *0x106B818
00000020 u64 hIcon = NULL
00000028 u64 hCursor = LoadCursorA(NULL, IDC_ARROW)
00000030 u64 hbrBackground = GetStockObject(LTGRAY_BRUSH)
00000038 u64 lpszMenuName = NULL
00000040 u64 lpszClassName = "WavMix32" (0x10050B0)
我想不出為什麼同一個結構在同一個二進位檔中會有不同的對齊,除非它們來自以不同旗標編譯的不同物件。
無論如何,在 4 個地方中的 3 個修復了 WNDCLASSA 結構對齊後,我再次執行彈珠台。這一次,它建立了全螢幕視窗並嘗試繪製啟動畫面,然後又發生記憶體區段錯誤:

當機記錄顯示,記憶體區段錯誤發生在 Win32 音訊系統深處,呼叫 auxSetVolume 時:
*----> Stack Back Trace <----*
FramePtr ReturnAd Param#1 Param#2 Param#3 Param#4 Function Name
000003FFFFF8E9C0 0000000050306E84 00000000FFE5D420 0000000000000000 000003FFFFE5D420 0000000000000000 !<nosymbols>
000003FFFFF8E9E0 00000000503034B8 00000000FFE5D420 0000000000000000 000003FFFFE5D420 0000000000000000 !<nosymbols>
000003FFFFF8EA10 0000000050304B60 00000000FFE5D420 0000000000000000 000003FFFFE5D420 0000000000000000 !<nosymbols>
000003FFFFF8EA90 0000000050305B8C 00000000FFE5D420 0000000000000000 000003FFFFE5D420 0000000000000000 !<nosymbols>
000003FFFFF8EB20 000000000001AF78 00000000FFE5D420 0000000000000000 000003FFFFE5D420 0000000000000000 !<nosymbols>
000003FFFFF8EB60 0000000000025AFC 00000000FFE5D420 0000000000000000 000003FFFFE5D420 0000000000000000 !auxSetVolume
000003FFFFF8EBD0 0000000000025F98 00000000FFE5D420 0000000000000000 000003FFFFE5D420 0000000000000000 !mixerSetControlDetails
000003FFFFF8EC80 0000000000027214 00000000FFE5D420 0000000000000000 000003FFFFE5D420 0000000000000000 !mixerSetControlDetails
000003FFFFF8ED30 0000000000030644 00000000FFE5D420 0000000000000000 000003FFFFE5D420 0000000000000000 !mciSendCommandW
[...]
000003FFFFF8FC60 0000000001012AB4 0000000000000000 000003FFFFE5DE00 000003FFFFE5D420 0000000000000000 !CreateWindowExA
000003FFFFF8FCA0 0000000001064B0C 0000000000000000 000003FFFFE5DE00 000003FFFFE5D420 0000000000000000 !<nosymbols>
000003FFFFF8FED0 0000000068948C50 0000000000000000 000003FFFFE5DE00 000003FFFFE5D420 0000000000000000 !<nosymbols>
000003FFFFF8FFC0 0000000000000000 0000000001064800 000003FFFFE5DE00 000003FFFFE5D420 0000000000000000 !BaseProcessStart
錯誤發生在嘗試解參考暫存器 a0(r16)中的指標時:
function: <nosymbols>
00000000'50305658: 00000000 halt
00000000'5030565c: 00000000 halt
00000000'50305660: 23deffe0 lda sp,-20(sp)
00000000'50305664: b53e0000 stq s0,0(sp)
00000000'50305668: b55e0008 stq s1,8(sp)
00000000'5030566c: b57e0010 stq s2,10(sp)
00000000'50305670: b75e0018 stq ra,18(sp)
00000000'50305674: 47f00409 bis zero,a0,s0
00000000'50305678: 47f1040a bis zero,a1,s1
00000000'5030567c: 47ff040b bis zero,zero,s2
FAULT ->00000000'50305680: a2100128 ldl a0,128(a0)
00000000'50305684: 20500001 lda t1,1(a0)
00000000'50305688: e440001b beq t1,00000000'503056f8 00000000'503056f8
00000000'5030568c: d35ff6f8 bsr ra,00000000'50303270 00000000'50303270
00000000'50305690: e4000019 beq v0,00000000'503056f8 00000000'503056f8
00000000'50305694: 47e00411 bis zero,v0,a1
00000000'50305698: 454b0801 xor s1,s2,t0
00000000'5030569c: e4200005 beq t0,00000000'503056b4 00000000'503056b4
00000000'503056a0: a2090128 ldl a0,128(s0)
00000000'503056a4: d35ff722 bsr ra,00000000'50303330 00000000'50303330
00000000'503056a8: 4160300b addl s2,#1,s2
00000000'503056ac: 47e00411 bis zero,v0,a1
暫存器傾印顯示當機時 a0 的值:
State Dump for Thread Id 0x150
v0=000003ff ffe5de00 t0=00000000 00000000 t1=00000000 00000058
t2=00000000 50306150 t3=00000000 0000015e t4=00000000 00000001
t5=00000000 00000001 t6=00000000 50300000 t7=00000000 00ed39fb
s0=00000000 ffe5d420 s1=00000000 00000000 s2=00000000 00000000
s3=00000000 00000000 s4=00000000 00000001 s5=00000000 50305a10
fp=00000000 000123b8 a0=00000000 ffe5d420 a1=00000000 00000000
a2=000003ff ffe5d420 a3=00000000 00000000 a4=00000000 00000000
a5=00000000 cc5a4dbc t8=00000001 00000000 t9=00000000 00000612
t10=d1b71758 e219652c t11=00000000 00000612 ra=00000000 50306e88
t12=00000000 00000000 at=00000000 00010000 gp=00000000 00000000
sp=000003ff fff8e9c0 zero=00000000 00000000 fpcr=89000000 00000000
SoftFpcr=00000000 00000000 fir=50305680
psr=00000003
mode=1 ie=1 irql=0
確實是一個無效的指標!如你所見,它與 a2 中的指標相同,但整個頂部的 32 位元被歸零。它一定是在某處被截斷的錯誤,無論是在音訊子系統中還是在 Pinball 本身。
我花了一些時間並鎖定了導致錯誤的 DLL——mciseq.dll,並進行了一些追蹤。a0 的截斷發生在 a2 被移入 a0 時:
50306150 ZAPNOT a2,#15,a0
ZAPNOT 是一個有趣的指令——它接收一個來源暫存器、一個位元遮罩和一個目的暫存器,並將位元遮罩中對應位元為 0 的位元組「歸零」。在這個例子中,位元遮罩是 15,二進位為 00001111。由此我們可以推斷出 0x50306150 處的 ZAPNOT 指令會將 a2 的上層 4 個位元組歸零,當它被複製到 a0 時。這完美地解釋了為什麼在發生錯誤時,a0 包含 a2 中指標的截斷版本。
當然,0x50306150 並不是唯一截斷 64 位元指標的地方,我在 mciseq.dll 中發現了 6 處完全相同類型的截斷。我一點也不清楚它為什麼決定截斷指標。如果我必須猜測,也許它們有指標 → 整數 → 指標的轉換,無論是什麼原因,那個整數類型是 32 位元。在修補了所有 6 處截斷後,我們就有了自己的彈珠台:

這是彈珠台確實在 64 位元 Alpha NT 組建上執行的證明:

要在你的 NT 組建 2210 安裝上執行彈珠台,請將 %ProgramFiles%\Windows NT\Pinball\pinball.exe 和 %windir%\system32\mciseq.dll 替換為以下檔案:
- 已修補的
pinball.exe - 已修補的
mciseq.dll
你也可以修補 安裝檔案 並將它們燒錄到新的 CD 上,如果你希望彈珠台在新安裝上立即可用——只需將這些檔案複製到安裝光碟的 AXP64 目錄:
- 已修補的
PINBALL.EX_ - 已修補的
MCISEQ.DL_
錯誤
現在我要讓你失望的是,我並沒有找到 Raymond 所說的碰撞偵測器錯誤。在修補了結構對齊和指標截斷問題後,遊戲現在完美運作。好吧,我不確定它是否真的完美,但我玩的幾場遊戲中從未看到任何故障。至少,球不會掉落穿過發射器,可以被發射並在桌台上彈跳自如。

以下是我認為沒有看到碰撞偵測器錯誤的兩個原因:
- 這個錯誤是在組建 2210 之後引入的。組建 2210 預設安裝了彈珠台,比 Windows XP 早了將近一年半,所以它幾乎可以肯定早於 Raymond 移除它。在這個組建中,彈珠台預設甚至無法執行,所以他們不可能測試它並看到這個錯誤。他們可能是在修復結構對齊和指標截斷問題後,才開始測試彈珠台。
- 這個錯誤只在 free/release 組建中出現,而不是在 checked/debug 組建中。也許它只在以發行組建所使用的更積極最佳化編譯程式碼時才會出現——當程式碼有未定義行為或編譯器有錯誤時,這種情況經常發生。然而,這不太合理,因為我相信 Raymond 在嘗試除錯時會使用除錯組建,並發現除錯組建和零售組建之間的任何差異。
當然,只有 Raymond 本人才能對這個主題提供更多說明。除錯彈珠台以及 NT 核心以修復模擬器是很有趣的(說真的:很痛苦),太多樂趣(說真的:痛苦)我再也不會做了。
關於我和彈珠台的一些趣聞:
我在幼稚園和學齡前花了相當多的時間玩我爸爸安裝在我們 Windows XP 家用電腦上的各種遊戲,然而,有一個遊戲我一直無法弄清楚如何玩——彈珠台。它隨作業系統附贈,而啟動畫面每次我試圖開啟它時都會嚇到我。

在我 3 歲時,彈桿看起來像一把手槍,而啟動畫面的整體黑暗感讓我感到恐懼。我會開啟遊戲,閉上眼睛,數到 20,然後再睜開眼睛,以跳過啟動畫面。
我第一次真正玩一整場彈珠台遊戲是在今年元旦,我的一個朋友帶我去了一家遊戲廳。在現實生活中玩了彈珠台之後,彈珠台遊戲終於開始有意義了。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.