OSに標準搭載されるゲームの中で最も人気があるもののひとつが、Pinballで、正式名称は3D Pinball for Windows – Space Cadetです。このゲームは元々Full Tilt! Pinballとして、Cinematronicsが開発し、Maxisがパブリッシングしました。3種類のテーブルが用意され、そのうちのひとつであるSpace CadetがMicrosoftにライセンスされ、Microsoft Plus! 95やその後のWindowsに組み込まれました。

Pinballが最後に搭載されたWindowsはXPで、Raymond Chenが自身のブログでVistaに搭載されなかった理由を説明しています。64ビットWindows向けにコンパイルすると衝突判定のバグがあり、ボールがさまざまなオブジェクトをすり抜けてしまうという問題で、たとえばプランジャーからボールが打ち出されずに画面から落ちてしまうことがありました。このバグによりゲームがプレイ不能となり、Raymondと同僚は合理的な時間内で修正を見つけられなかったため、削除したとされています。少なくともこれが10年近くにわたり語られてきた経緯です。

2021年にNCommander一連の調査を開始し、Windows XPおよびVistaプレリリース版の各種64ビット(IA-64およびAMD64)ビルドでPinballをテストしました。64ビット版Pinballはごくわずかな不具合を除き、ほぼ問題なくプレイ可能であることを発見し、削除された理由はWindows VistaのデザインにUIが適合しなかったためだと推測しました。

NCommanderの動画公開後、Raymondは追記記事で物語の隙間を埋め、バグに関する詳細を明らかにしました。それによると、極めて深刻な衝突判定バグを抱えていたのは64ビットAlpha AXP版のPinballでした。この主張は過去5年間、以下のような理由で検証不能でした。

  • Alpha AXP向けの64ビットWindowsは一度もリリースされなかった。CompaqがNTを64ビットへ移植する前にWindows NTサポートを打ち切ったため
  • 64ビットAlpha AXP向けNTのビルドが2023年にリークされたが、付属のPinballは起動直後にセグフォルトを起こして動作しない

筆者はDEC Alphaに以前から関心を持っており、主にDECアーキテクチャとUNIXへの愛情からです。VAXはPDP-11の直接の後継であり、AlphaはVAXの直接の後継です。以前にAlphaエミュレーションの進展があり、友人たちからNT 4.0がES40エミュレータのフォークQEMUで動作するようになったと連絡がありました。Alpha向けNTがエミュレータで動作するとは考えていませんでした。なぜなら、馴染みのあるTru64、Linux、BSDとは異なり、NTは独自のPALcodeを使い、SRMではなくARC(Advanced RISC Computing)に依存するためです。もちろん、エミュレータがAlpha NTの聖杯であるWindows(XP?)ビルド2210を起動できないという指摘もありました。QEMUではメモリ管理エラーでカーネルパニックを起こし、ES40ではキーボードを検出できずに異常終了していました。シンボルのないNTカーネルで何度か地獄のような作業を繰り返し、MMUエミュレーションを修正した結果、唯一残された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 ->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 

つまり、重要なWin32 API関数であるRegisterClassAの中でクラッシュしたことになります。このAPI関数自体にバグがあるはずはありません。なぜなら、もしバグがあれば、すべてのGUI Win32プログラムが動作しなくなるからです。つまり、エラーの原因となり得るのは唯一の引数、WNDCLASSA構造体へのポインタだけです。言うまでもなく、ポインタ自体は有効でした。さもなければAPIが無効な引数を検出するか、もっと早くセグフォルトが発生していたはずです。

スタックトレースから、RegisterClassAのリターンアドレスはsplash_screen関数内の0x100F914でした。このアドレスの前の命令を逆アセンブルすると、以下のようなレイアウトの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ビットフィールドは2バイト境界に、32ビットフィールドは4バイト境界に、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 !<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

フォルトはレジスタ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です。トレースを行ったところ、a2a0に移動される際にa0の切り詰めが発生していました。

50306150    ZAPNOT  a2,#15,a0

ZAPNOTは興味深い命令です。ソースレジスタ、ビットマスク、デスティネーションレジスタを受け取り、ビットマスクの対応するビットが0であるバイトを「zap」(ゼロ化)します。この場合、ビットマスクは15で、2進数で00001111です。これにより、0x50306150ZAPNOT命令がa2の上位4バイトをゼロ化し、a0にコピーすることがわかります。これにより、フォルト発生時にa0a2のポインタの切り詰められたバージョンになっていたことが完全に説明されます。

もちろん、0x50306150だけが64ビットポインタを切り詰めていたわけではありません。mciseq.dll内に同じ種類の切り詰めが6箇所見つかりました。なぜポインタを切り詰めることにしたのか、全くわかりません。推測するなら、何らかの理由でポインタ→整数→ポインタのキャストが行われ、その整数型が32ビットだったのかもしれません。6箇所の切り詰めをすべてパッチで除去した結果、Pinballが動作するようになりました。

以下は、Pinballが実際に64ビット版Alpha NT上で動作していることを示す証拠です。

NTビルド2210のインストールでPinballを動作させるには、%ProgramFiles%\Windows NT\Pinball\pinball.exe%windir%\system32\mciseq.dllを以下のパッチ適用済みファイルに置き換えてください。

また、インストールファイルにパッチを適用し、新しいCDに焼くことで、クリーンインストールでも最初からPinballが動作するようにできます。インストールディスクのAXP64ディレクトリに以下のファイルをコピーしてください。

バグ

ここで、Raymondが語っていた衝突判定バグは見つからなかったことをお伝えしなければなりません。構造体アライメントとポインタ切り詰めの問題を修正した結果、ゲームは完璧に動作するようになりました。まあ、実際に完璧かどうかはわかりませんが、私がプレイした数回のゲームでは不具合は見られませんでした。少なくとも、ボールがプランジャーから落ちることはなく、打ち出されてテーブル上を跳ね回ることは問題なくできました。

衝突判定バグが見られなかった理由として考えられる2つの理由を以下に示します。

  • バグはビルド2210以降に導入された。ビルド2210にはPinballがデフォルトでインストールされており、Windows XPの約1年半前に作成されたため、Raymondが削除するよりも確実に前です。このビルドではPinballはデフォルトで動作しないため、テストしてバグを発見することはできませんでした。おそらく、構造体アライメントとポインタ切り詰めの問題を修正した後で、Pinballのテストを開始したのでしょう。
  • バグはfree/releaseビルドでのみ発生し、checked/debugビルドでは発生しない。releaseビルドで使用されるより積極的な最適化でコンパイルされた場合にのみ現れる可能性があります。これは、コードに未定義動作がある場合やコンパイラにバグがある場合によく起こります。ただし、Raymondがデバッグを試みた際にdebugビルドを使用し、debugビルドとretailビルドの違いを発見していたはずなので、この可能性は低いでしょう。

もちろん、このトピックについてより詳しく語ることができるのはRaymond本人だけです。Pinballのデバッグ、そしてエミュレータを修正するためのNTカーネルのデバッグは楽しい(読む:苦痛な)ものでした。二度とやりたくないほど楽しい(読む:苦痛な)ものでした。


私とピンボールに関する些細な話:

幼稚園や保育園の頃、父がWindows XPの家庭用コンピュータにインストールしたさまざまなゲームをよくプレイしていましたが、遊び方がよくわからなかったゲームが1つありました。それがPinballです。OSにバンドルされており、スプラッシュ画面を見るたびに怖がっていました。

3歳の私にはフリッパーがピストルのように見え、スプラッシュ画面の全体的な暗さが恐怖心を植え付けました。ゲームを開き、目を閉じて20まで数え、再び目を開けてスプラッシュ画面をスキップしていました。

実際にピンボールのフルゲームをプレイしたのは、今年の元日に友人がアーケードに連れて行ってくれたときでした。現実のピンボールをプレイした後、ようやくPinballゲームが理解できるようになりました。