世界を動かす技術を、日本語で。

ドンキーコング カントリー2とオープンバス

概要

  • Donkey Kong Country 2 の特定ステージで、 ZSNESエミュレータ のバグにより回転バレルが正常に動作しない現象
  • バグの原因は open bus挙動の未実装 によるもの
  • 実機や他エミュレータ(Snes9x)では正しく動作し、 バレルの停止判定 にopen busの値を利用
  • 65816 CPU のアドレッシングやメモリバンク構造の説明
  • ゲーム内部の処理やアセンブリコード解析を通じたバグの詳細解説

Donkey Kong Country 2とZSNESのバレル回転バグ

  • Donkey Kong Country 2の一部ステージ(例: Barrel Bayou)で回転バレルの操作が異常になる現象
    • 本来:プレイヤーが 左/右ボタンを押している間だけ回転
    • ZSNESでは:一度押すと 永遠に回転 し続け、逆方向を押すと逆回転が止まらない
  • このバグは 難易度を大幅に上昇 させ、本来のゲーム性を損なうもの

原因:Open Bus挙動の未実装

  • SNES実機では 未マッピングアドレス へのアクセスで「open bus」挙動が発生
    • 直前にバスに載った値が再読込される仕様
  • ゲームは バレル停止判定 にこのopen bus値を利用
  • ZSNESではopen bus挙動が未実装のため、 常に0が返り、判定が正常に働かない
  • Snes9x等の他エミュレータでは バグが修正済み

65816 CPUとメモリアドレッシング

  • SNESのCPUは 65C816(65816)
    • 24ビットアドレスバス、8ビットバンク+16ビットオフセットで管理
    • プログラムバンク(PBR)データバンク(DBR) の概念
  • メモリバンク$B3の$2000/$2001アドレスは 未マッピング でopen busとなる
  • 16ビットアクセス時は 2回の8ビットリード を連続実行
  • ゲームコードはこの仕様を想定して記述

ゲーム内部処理の解析

  • バレル回転処理のアセンブリコード例
    • バレルの向き回転量一時変数 の管理
    • 回転停止判定は XOR+AND演算 で、open bus値(本来は0x2020)を利用
  • ZSNESではopen bus値が0となるため、 停止判定が常に失敗 し回転し続ける
  • 実機やSnes9xでは 0x2020 が返ることで、バレルが正しい方向で停止

まとめ

  • Donkey Kong Country 2の 回転バレルバグ はZSNESの open bus未対応 が原因
  • ゲームはopen bus挙動を前提に判定を組んでおり、エミュレータの再現性が重要
  • ZSNESは開発終了 (最終リリース2007年)、今後の修正は見込めない
  • バグ回避には 他エミュレータの利用 推奨

Hackerたちの意見

こういうの大好き!アセンブリコードの理解度が60%くらいしかないから、文章での説明がめっちゃ助かる。あと、「誰も理解してなかったバグ」みたいな話を聞くのも楽しいよね!

この時代のシステムで好きなところの一つは、今ではほとんどの組み込みシステムで当たり前とされている現代のチェックがなかったことだね(ネットワークに接続できるものには必要で、完全に孤立した組み込みアーキテクチャでも安価だから含まれている)。オリジナルのNESでは、たくさんの読み書きがどこかのラインの電圧を切り替えるだけで、その後何が起こるかはそのままだった。CRTのブランキング間隔の動作を示す信号と同期して、非常に制御された方法でその電圧を切り替えることで、欲しい効果が得られたんだ。スーパーマリオブラザーズ3の一部のアニメーションでは、複数のスプライトデータバンクから選択するためにRAMのマルチプレクサを切り替えて、グラフィックハードウェアがスプライトを引っ張るときに、見た目が少し異なる全く別のチップから引っ張るようになってた。テレビのタイミングが重要だったから、NTSCとPALのテレビがある地域では、異なるリフレッシュレートで動作していたので、異なるソフトウェアをリリースする必要があったんだ。あの時代は本当にワイルドだった。

6502アセンブリプログラマーとして言わせてもらうと、同じ問題を追いかけるのに無駄に何時間も費やしたことがあるよ。即値の前に#を付けるのを忘れて、メモリアクセスしちゃうってやつね。こういうケースって、時々はうまく動くこともあるから厄介なんだよね。この例よりも厄介なのは、初期化されていないRAMに依存する場合。DRAMによって一貫性があるから、自分のマシンやエミュレーターでは動くけど、違うDRAMチップのマシンでは動かないことが多い。デモパーティーでこれにハマると、パーティーのマシンで動かなくて、デモを発表する前に15分しか修正時間がないっていうのが定番だよね。

6502 CPUを使った動的メモリを持つアーキテクチャってあったのかな?私の(限られた?)経験では、そのプラットフォームは常に静的RAMだった。

6502は私の最初のアセンブリ言語で、「LDA #2」みたいな命令は「Aに2をロードする」って考えてたけど、LDA 2は(メモリの位置2にあるものを)Aにロードするって感じだった。

こういう状況では、コードをLLMに通すのが実際に役立つことがあるんだよね。こういう深刻な影響を与えるエラーやタイプミスを見つけるのが得意だから、目がスルーしがちなところを見逃さないんだ。

OTだけど、30年も経ったゲームなのに、DKカントリー2が今でもこんなに楽しめるのはすごいと思う。エミュレーターでプレイしてるけど、グラフィック、音、レベルデザイン、操作性が全て素晴らしい。子供たちはフォートナイトを楽しんでるけど、俺はいつでもDKCとクロノトリガーを選ぶよ!

クロノトリガーは今でも素晴らしい。あのゲームは名作だね。

あの時代のRareが開発したゲームは、ほとんどが本当に良くできてたよね。

3年前に初めてオリジナルのDKCトリロジーをプレイしたけど、1と2はあんまり好きじゃなかったな。操作がすごく「フローティー」に感じて、難易度も調整がイマイチだった。DKC 2の鳥のステージは特にイライラさせられた、どれか分かるでしょ。だけど、DKC 3はすごく楽しめた。どうやらハードコアなDKCファンにはあんまり人気がないみたいだけど、それはそれで。

エミュレーションでゲームをプレイしてて詰まると、「これエミュレーターのバグかな?」って思うことがある。この特定の問題は、ゲームがこういう風に設計されてるんだと思ってた。あんまり関係ないけど、ゲームがすごく難しいと「これはエミュレーションの遅延のせい?」って感じることもある。これについては深く調べて、ミスターFPGAを作ったよ!

クロノトリガーにもこんなのあった気がする。ネズミを捕まえるセクションがあって、その後に4つのキーを同時に入力しなきゃいけなかったんだ。でもUSB入力は一度に3つしか送れないから、4つを一気に押して、短い時間内に登録されるのを待つしかなかった。何度も挑戦して、すごくイライラしたよ。

子供の頃、バイオニックコマンドーをめっちゃやってたんだ。2000年代初頭にエミュレーターで起動したら、思ってたよりずっと難しかった。敵が基地を爆破しても消えないエミュレーションバグがあることに気づいたんだ。でもラッドは凍ったままだったから、レベルクリアするのに大体2つくらい余分なライフポイントが必要だった。試しにその方法で一回クリアしたけど、二度とやらなかった。

なんでこれでダウンボートされたんだろう?

DKCはZSNESでしかプレイしたことがなくて、この記事を読むまでこれがエミュレーターのバグだとは全然知らなかった。君が言ったように、バレルからの発射タイミングが正しい角度になるようにするのが意図されたゲームデザインだと思ってたから、驚いたよ、これがバグだなんて!

6502っぽいミスをすることはあまりないけど、する時はだいたい即値じゃなくてメモリアドレスの方だね!これは非常に一般的で簡単に犯しやすいミスで、チャック・ペドル自身も即値のための#$1234構文を深く後悔してたと思う。IDEで#を明るい赤にしてるけど、ちょっとは助かるかな… RareのASM神たちも同じ問題にやられてたよ!

ずいぶん前に、GNUアセンブラの「intel_syntax noprefix」モードで似たような問題に遭遇したことがある。前方参照の名前付き定数即時を、即時かメモリアドレスのどちらかを受け入れる命令内で未知のシンボルへの参照として解釈できる文法的あいまいさがあったんだ。結果的に、期待される即時ではなく、シンボルの再配置アドレスで埋められることが期待されるプレースホルダーのメモリアドレスを持つ命令がアセンブルされてしまった。デバッグが大変だったよ。

ARMみたいな命令セットは、そういうミスをするのがほぼ不可能にしてるよ。メモリを使う時には、別の命令を使わなきゃいけないからね。

オープンバスについて理解するためにこれを読み始めたんだけど、タイトルに大文字で書かれてたから、何か古いバスプロトコルかスタンダードの固有名詞だと思ってたんだ。読んでみたら、単に「オープン」っていうのは、何にも接続されてない状態のことを指してた。アドレスラインのデコーダーが指定されたアドレス($2000)でメモリデバイスを有効にしてなかったからね。即時モード(#)の省略が、古いエミュレーターが実際のハードウェアと同じようにメモリから読み込まなかった時まで気づかれなかったのは面白い。命令を絶対アドレスから即時アドレスモードに変更する解決策は、メモリからの読み込みを実行しなくなるから、実行時間が速くなる結果になる。おそらくそのコードの部分で約2us速くなったけど、これはベアメタルでしか意味がないかもね。エミュレーターは多分、時間的に完璧じゃないし。

おそらくそのコードの部分で約2us速くなったけど、これはベアメタルでしか意味がないかもね。エミュレーターは多分、時間的に完璧じゃないし。 (一部の)SNESエミュレーターは、今や基本的に時間的に完璧だよ、今のところ[0]。でも、2usは特別なケースを除いては大きな違いにはならないね。 [0] https://arstechnica.com/gaming/2021/06/how-snes-emulators-go...

Rareは、テストでは動くけど、何年もバグが埋もれているゲームの歴史があるんだ。新しいアーキテクチャがそれを浮き彫りにするまでね。他の会社がそうじゃないってわけじゃないけど、Rareはこの話題で引用しやすい名前なんだ。ドンキーコング64にはメモリリークがあって、(その時代としては)あり得ないくらいの連続プレイ時間(8-9時間、私の理解が正しければ)でゲームが死んじゃうんだ。それは開発中に見つからなかったけど、エミュレーターのセーブステートで進行状況を保存してプレイしていると、簡単にその時間に達しちゃう。 (注:ここにはあいまいな歴史がある。一部の情報源は、メモリパック付きで出荷されたのはバグを隠すための最後の手段で、クラッシュウィンドウを8-9時間から13-20時間に押し出すためだったと主張している。最近の研究では、それは偶然で、Rareや任天堂がそのバグを認識していなかったということが示唆されている。)

オープンバスって、データバスのラインがオープン回路になってるってことなんだ。CPUがアドレスバスにマッピングされていないか、書き込み専用のアドレスを置いたけど、バス上のハードウェアが反応しないから、バスラインがドライブされずにフローティング状態になってる。つまり、名目上はハードウェアレベルで未定義の動作ってことになる。実際に何が起こるかを理解するには、データバスの物理構造をもう少し詳しく見る必要があるよ。マザーボードやカートリッジに信号を運ぶ長い導体があって、薄い絶縁基板でグラウンドプレーンから隔てられてる。これ、コンデンサみたいに見えるし、実際にエンジニアたちはこれを「寄生容量」として説明して、最小化しようとしてるんだ。この効果がデータ転送の最大速度を制限するからね。でも、この効果のおかげで、バスがドライブされていないときは、最後にドライブしてた電圧を維持する傾向があるんだ。まるで小さなDRAMセルみたいにね。記事で説明されてる「オープンバスのリードは最後に転送された値を返す」っていう効果がそれ。ゲームがオープンバスの効果に偶然依存することも珍しくないよ、例えばDKC2みたいに。NESでは、コントローラーに接続するためのシリアルポートレジスタは低位ビットしかドライブせず、高位ビットはオープンバスになってる。いくつかのゲームはLDA $4016の命令でコントローラーの入力を読み取って、$40か$41の値が返ってくることを期待してる(4がオープンバスの影響で残るから)。オープンバスの動作に依存するスピードラン戦略もあって、例えばスーパーマリオワールドのクレジットワープなんかは、プログラムカウンタがマッピングされていないメモリを通ってRAMに到達し、敵の位置を巧みに操作して作ったペイロードを実行するんだ。だけど、通常の予測可能なオープンバスの動作には例外もある。非標準のカートリッジは、マッピングされていないメモリにデフォルト値を返したり、オープンバスの動作に影響を与えるプルアップやプルダウン抵抗を含んでいることがある。DMAとの面白い相互作用もあって、SNESはHDMAという機能をサポートしてる。これにより、アプリケーションはDMA転送をスケジュールして、CPUからグラフィックハードウェアにデータを正確なタイミングで転送できるんだ。これにより、フレームの途中でデータをアップロードしたり設定を変更したりできる。DMA転送は一時的にCPUを停止させて、バスを使って転送を行うから、命令の途中でDMA転送が発生するとオープンバスのリードの動作が変わることがある。この非常にニッチなエッジケースが、スーパーメトロイドのスピードランのエクスプロイトに大きな影響を与えるんだ。オープンバスからRAMに大きなデータブロックを転送しようとするアウトオブバウンズのmemcpyを引き起こすんだ。オープンバスのリードはほとんど常にゼロを返すけど(関連するロード命令の最後のバイトがゼロだから)、HDMAが多いグラフィック効果のある特定の部屋で行うと、DMA転送がリードの一つに影響を与えて、重要なところに非ゼロバイトが忍び込む可能性が高くなり、エクスプロイトが正常に動作せずにクラッシュすることがある。これがコミュニティで軽い論争を引き起こしていて、いくつかのルートや戦略はエミュレーターや非標準のファームウェアでしか信頼できないんだ。オリジナルハードウェアや非常に正確なエミュレーターを使っているプレイヤーは、クラッシュする可能性が高いけど、ほとんどのエミュレーター(任天堂の公式リリースを含む)は、このニッチなエッジケースでのHDMA転送がオープンバスのリードの値を変えるのをエミュレートしていない。現在のスーパーメトロイドの最速TASクリアもこのHDMAの相互作用に依存してる。オープンバスを実行しようとしたクラッシュを見つけたけど、通常は有用な方法で制御できなかった。部屋の敵を操作してCPUのタイミングに影響を与えることで、HDMAを使ってバスに有用な命令を正しいタイミングで載せることができて、最終的にコンソールがコントローラーの入力をコードとして実行し、完全な任意コード実行を達成したんだ。

... データバスの物理構造をもう少し詳しく見る必要がある またベン・イーターに感謝しなきゃ。彼の6502を使ったブレッドボードコンピュータの動画シリーズのおかげで、この記事が何を言ってるのか、ハードウェアの問題を説明するときに何を指してるのかが理解できたんだ。(もちろん、彼の基本的なバスの例から商業機械に extrapolate してるけど。)そうじゃなかったら、全然わからなかったよ。

一度、SNESのぷよぷよでPPUオープンバスを体験したことがある。これはRetroArchのRunAhead機能を作っているときで、セーブステートが一致しないときにチェックしてたんだ。CPUの実行トレースログが一致しなかったのは、PPUオープンバスから読み取った値がセーブステートをロードした後に一致しなかったから。

DKC 1のSGIプリレンダリングの3Dグラフィックスは最先端だったね。ジェネシスのベクターマンも似たようなことをやってたけど、あまり評価されなかった。

自分は95年頃のDKCのターゲットデモにぴったりの子供だったんだよね。11歳だった。あのゲームには本当に驚かされた。見ているものが信じられなかったのを覚えてる。リリース時期にゲームのティーザーを含むビデオテープをもらったんだけど(多分、シリアルの箱からのプロモーションで送られてきたやつ)、開発の裏側の映像もあったんだ。あのテープは何度も見たな。DKCを自分で持ってはいなかったけど、友達の家で遊ぶことはできたよ。

確かに「オープンバス」っていうのは、初期のシンプルな同期バスのシステムだけに見られるもので、他のほとんどのシステムでは存在しないアドレスにアクセスしようとすると、常にゼロかワンの値が返ってくると思う。バスプロトコルにはハンドシェイクがあって、マスターが応答がないことを知ることができるからね(PCI用語で言うところの「マスターアボート」ってやつ)。