最受欢迎的操作系统内置游戏之一无疑是 Pinball,其全称为 3D Pinball for Windows – Space Cadet。它最初是 Full Tilt! Pinball,由 Cinematronics 开发、Maxis 发行。该游戏提供了 3 个桌面,其中一个 Space Cadet 被授权给 Microsoft,包含在 Microsoft Plus! 95 中,后来内置于 Windows 操作系统中。

Windows XP 是最后一个包含 Pinball 的 Windows 版本,Raymond Chen 在他的博客中解释了它为何未出现在 Windows Vista 中。原因是它在为 64 位 Windows 编译时存在碰撞检测器错误,导致球会穿过各种物体——例如从柱塞下方掉落,而不是被发射出去。这个错误使游戏无法玩,Raymond 和他的同事在合理时间内无法找到修复方法,因此他将其移除。至少这是我们被讲述的故事,大约持续了十年。

2021 年,NCommander 发起一系列调查来质疑这一说法,在各种 64 位(IA-64 和 AMD64)Windows XP 和 Vista 预发布版本上测试 Pinball。他发现 Pinball 的 64 位版本都高度可玩,只有非常小的故障,并推测移除的原因是 UI 不符合 Windows Vista 的设计。

在 NCommander 发布视频后不久,Raymond 跟进了一篇帖子,填补了故事中的一些空白,并对该错误提供了更多信息。他说具有极差碰撞检测错误的是 64 位 Alpha AXP 版本的 Pinball。这一说法在过去 5 年中无法验证,原因如下:

  • 从未为 Alpha AXP 发布过 64 位 Windows——Compaq 在 NT 被移植到 64 位之前就终止了对 Windows NT 的支持
  • 2023 年泄露了一个 64 位 Alpha AXP NT 版本,但其中包含的 Pinball 无法运行,运行时立即出现段错误

我对 DEC Alpha 一直很感兴趣,主要是出于我对 DEC 架构和 UNIX 的热爱。VAX 是 PDP-11 的直接继承者,Alpha 是 VAX 的直接继承者。此前,一些 Alpha 模拟器取得了突破性进展,几个朋友向我指出 NT 4.0 现在可以在 ES40 模拟器的分支以及 QEMU 上运行。我从未想过 Alpha NT 会在模拟器下运行,因为与熟悉的 Tru64、Linux 和 BSD 不同,NT 使用自己定制的 PALcode,并依赖 ARC(高级 RISC 计算)而非 SRM。当然,人们注意到模拟器无法运行 Alpha NT 的圣杯——Windows(XP?)build 2210,因为其内核会在 QEMU 中因内存管理错误而崩溃,或者在 ES40 中无法检测键盘并出现错误。在无符号的 NT 内核中经历了几次地狱之旅以及一些 MMU 模拟修复之后,我能够修补 QEMU 和 ES40 以启动这个仅存的 64 位 Alpha NT 版本。

在调试无符号 NT 内核而没有内核调试器的情况下折磨我的大脑之后,我想尝试修复 Pinball,让这一切值得。调试用户态进程的好处之一是,虽然仍然没有调试器,但有 Dr. Watson,它可以获取核心转储并执行简单的事后分析。有总比没有好,正如人们所说。

运行 Pinball 立即给出经典的崩溃症状,没有绘制任何图形:

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 ->000im0000'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,为了满足对齐要求,应该在 stylelpfnWndProc 之间插入 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_Initsplash_screenWinMainWaveMixStartup。我已经修补了 splash_screen 中的那个,所以我开始逐个检查其余的。

Sound_InitWinMain 中的那些与 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 结构对齐之后,我再次运行 Pinball。这次它创建了全屏窗口并尝试绘制启动画面,然后死于另一个段错误:

崩溃日志显示,段错误发生在 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 !CreateWindowExA 
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 0000000001002DC8 000003FFFFF8FAD8 0000000030010000 !BaseProcessStart

故障发生在尝试解引用寄存器 a0r16)中的指针时:

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 被复制到 a0 时清零了 a2 的高 4 字节。这完美地解释了为什么在故障发生时,a0 包含了 a2 中指针的截断版本。

当然,0x50306150 并不是唯一截断 64 位指针的地方,我在 mciseq.dll 中找到了 6 处完全相同类型的截断。我完全不知道为什么它决定截断指针。如果我必须猜测,也许他们出于某种原因进行了指针→整数→指针的转换,而那个整数类型是 32 位的。在修补了所有 6 处截断之后,我们就有了一些属于自己的 Pinball:

以下是 Pinball 确实在 64 位 Alpha NT 版本上运行的证明:

要在你的 NT build 2210 安装上运行 Pinball,请将 %ProgramFiles%\Windows NT\Pinball\pinball.exe%windir%\system32\mciseq.dll 替换为以下文件:

你也可以修补安装文件并将其刻录到新 CD 上,如果你想让 Pinball 在全新安装上开箱即用——只需将这些文件复制到安装光盘的 AXP64 目录:

错误

现在我要让你失望了,因为我没有找到 Raymond 谈到的碰撞检测器错误。在修补了结构对齐和指针截断问题后,游戏现在完美运行。好吧,我不确定它是否真的完美,但我玩的几局游戏中从未见过任何故障。至少,球不会从柱塞下方掉落,可以被发射并在桌面上反弹得很好。

以下是我认为没有看到碰撞检测器错误的两个原因:

  • 错误是在 build 2210 之后引入的。Build 2210 默认安装了 Pinball,比 Windows XP 早近一年半,所以它几乎肯定早于 Raymond 移除它。在这个版本中,Pinball 默认甚至无法运行,所以他们不可能测试它并看到错误。他们可能只是在修复了结构对齐和指针截断问题之后才开始测试 Pinball。
  • 错误仅在 free/release 版本中显现,而不在 checked/debug 版本中显现。也许它只在代码使用发布版本使用的更激进的优化编译时才会出现——当代码存在未定义行为或编译器存在错误时经常发生的事情。然而,这不太合理,因为我确信 Raymond 在尝试调试它时会使用调试版本,并发现调试版本和零售版本之间的任何差异。

当然,只有 Raymond 本人才能对此话题提供更多信息。调试 Pinball 以及 NT 内核以修复模拟器很有趣(读作:痛苦),太有趣了(读作:痛苦),我再也不会这么做了。


关于我和弹球游戏的一些琐事:

我在幼儿园和学前班的很大一部分时间里都在玩我爸爸安装在我们 Windows XP 家用电脑上的各种游戏,然而,有一个游戏我一直不太清楚如何玩——Pinball。它随操作系统捆绑,启动画面每次我试图打开它时都会吓到我。

在我 3 岁的自己看来,挡板看起来像手枪,启动画面的整体黑暗只是给我注入了恐惧。我会打开游戏,闭上眼睛,数到 20,然后再次睁开眼睛,以跳过启动画面。

我第一次真正玩一整局弹球游戏是在今年元旦,我的一个朋友带我去了一家街机。在现实生活中玩了弹球之后,Pinball 游戏终于开始有意义了。