1 ポイント 投稿者 GN⁺ 2025-07-02 | 1件のコメント | WhatsAppで共有
  • Donkey Kong Country 2の回転するバレルのバグは、ZSNES エミュレータで発生する
  • ZSNESはopen busの動作を正しくエミュレートしておらず、バレルが永続的に回転する問題が起きる
  • 実機とは異なり、ZSNESでは誤ったメモリアクセス時に常に0が返るため、バグが誘発される
  • 正しい動作では、バレルは正確な方向(8方向)で回転を停止するロジックになっている
  • この問題はコーディング上の些細なミス(つまり、即値アドレッシングの代わりに絶対アドレッシングを使用したこと)に起因すると推測される

Donkey Kong Country 2とZSNESエミュレータのバレルバグ

Donkey Kong Country 2には、ZSNESという古いSNESエミュレータで一部ステージの回転するバレル(樽)が正常に動作しない有名なバグがある

バレルに入ると、本来は方向キーの左/右を押している間だけバレルが回転するはずだが、ZSNESでは左/右を短く押しただけでもバレルがその方向に永遠に回転し続ける

このバグにより、特に後半ステージでいばらや障害物の上に現れる回転バレル区間が、開発者の意図よりはるかに難しくなってしまう

この問題は過去のZSNESフォーラムである程度文書化されていたが、現在はフォーラムが消滅しており、関連資料を見つけるのが難しい

バグの原因 - Open Bus Emulation

このバグの根本原因は、ZSNESがopen busの動作をエミュレートしていないことにある

  • open busは、SNESのような旧世代プラットフォームで無効なメモリアドレスを読み込んだ際に発生する動作である
  • 実機では、最後にバスに載っていた値が返される
  • SNESの主要CPUは65C816(65816)である
  • 65816は6502の16ビット版で、24ビットのアドレスバスを持ち、メモリバンキング方式を使用する

DKC2の回転バレルのコードでは、無効なアドレス(Bank $B3の$2000、$2001)にアクセスした際、実機ではopen busにより0x2020の値が返される

ZSNESにはこの機能がないため、常に0が返ることでバグが発生する

ゲームコードの動作方式

回転バレルに関連するゲームルーチンは、次のような流れで動作する

  • 現在のバレルの方向と回転量(速度)を加算して一時変数に保存する
  • XOR演算で方向の変化を測定し、その結果をopen busから読んだ値とAND演算する
  • そのAND結果が0なら回転を継続し、0でなければ停止して、方向を8方向のいずれかに丸めて整列させる

実機ではopen busの値は0x2020だが、もし0が返ると回転が無限に続く

このロジックは、本来AND演算を**即値(address #$2000)で行うべきところを、誤って絶対アドレス(address $2000)**を使用していたと推測される

しかし実機のopen bus特性により、実際にはどちらの方式でも正常に動作する

対応と結論

Snes9xのような他のSNESエミュレータはこのバグをハードコードで修正したが、ZSNESは開発が停止しているためパッチされていない

該当ルーチンでAND命令のオペコードを0x2Dから0x29(AND #$2000)に変更すれば、open busの動作がなくても回転バレルは正常に動作する

この問題は、実機や最新のエミュレータでは発生しない

結局のところ、このバグはopen busエミュレーション非対応コーディングミスが重なって発生した一例である


追加背景: 65816の構造とSNESメモリマップ

65816 CPUは24ビットのアドレスバスを持つが、主に8ビットのバンクと16ビットのオフセットの組み合わせを使う

  • プログラムカウンタ(PC)は16ビットで、プログラムバンクレジスタ(PBR, K)と組み合わせて全体アドレスを構成する
  • データバンク(DBR, B)はデータ演算用のバンク選択に使われる
  • ハードウェアスタックとdirect pageは常に$00バンクに存在する

SNESのメモリマップも65816を基盤に設計されており、アドレスを8ビットバンク+16ビットオフセットとして捉えるほうが効率的である

まとめ

この事例は、レガシーハードウェアの特性(open busなど)がエミュレーションにおいて予期しないバグにつながりうることを示している

開発者は即値アドレッシングを使うべきだったが、偶然にも絶対アドレスでも正常に動作していた事例である

現代では、open busの動作までエミュレートすることが、旧世代ソフトウェアの正確な再現に非常に重要であることを示唆している

1件のコメント

 
GN⁺ 2025-07-02
Hacker Newsの意見
  • 私は6502アセンブリのプログラマとして、#記号を付け忘れて即値ではなくメモリアクセスにしてしまうミスで、数え切れないほどの時間を無駄にした経験がある。こういうミスは、たまに運よく動いてしまうこともあるので、なおさら厄介だと実感している。だが、例に出ているフローティングバス問題よりさらに最悪なのは、未初期化RAMに依存するコードで、DRAMごとに初期値が異なるため、自分のマシンやエミュレータでは常にうまく動いても、別のDRAMを使う別のマシンでは失敗することがある。たいていはデモパーティーで他人のハードウェア上で動かさなければならず、締切15分前を切ってコードが動かないときに、こういう問題を発見することになる

    • 6502 CPUで動的メモリを使ったアーキテクチャが実際に存在したのか気になる。自分の経験では、その種のプラットフォームは常に静的RAMしか使っていなかった記憶がある

    • 6502が私にとって最初のアセンブリ言語で、LDA #2は「Aレジスタに数字の2をロードする」と考えていた。一方でLDA 2は「メモリ位置2番の値をロードする」という感覚だったので、この違いのおかげでそもそもミスを避けようとしていた

    • こういう状況では、LLMにコードを通してみるのがむしろ有用かもしれない。LLMはこうした影響の大きいタイプミスやミスしやすい箇所を見つけるのが得意だからだ

  • Open Busという単語が大文字で書かれていたので、何か古いバスプロトコルか標準のことだと勘違いして読み始めた。実際には、単にバスがどこにも接続されていない状態を意味していて、これはアドレスデコーダが指定したアドレス($2000)でどのメモリデバイスも有効化されていなかったためだと理解した。即値モード(#)を付け忘れたせいでメモリから何も読めなくなっていた現象を、古いエミュレータが実機と異なる挙動をしたことで発見した。解決策として即値アドレッシングモードに命令を変更すれば、もはやメモリ読み出しを行わないのでコードは約2us高速になる。ただし、この程度の性能差は実機でない限り、特にタイミングが完全一致していないエミュレータでは、あまり意味がないように思える

    • (一部の)SNESエミュレータは現在ほぼ時間ベースで完全性を達成しているという説明。ただし、2usの差は本当に例外的なケースでない限り、事実上目に見える差にはならない。関連記事: How SNES emulators got a few pixels from complete perfection

    • Rareのように、発売からかなり時間が経って新しいアーキテクチャのおかげで発見されるバグを抱えたゲームを、複数発売した例がある。Donkey Kong 64では8〜9時間連続でプレイすると致命的なメモリリークが発生するが、エミュレータのセーブ機能によってその時間が一気に積み上がり、バグが簡単に露出する。なお、発売時に同梱されたMemory Pakはそのバグを隠すためだったという説があるが、最近の調査によればRareもNintendoも当時そのバグを認識していなかった

  • SNES版Puyo PuyoでPPUオープンバス現象に遭遇したことがある。RetroArchでRunAhead機能を作業していたとき、セーブステートが一致しない理由を探っている過程で起きたもので、PPUオープンバスから読んだ値が状態ロード後に変わってしまい、CPU実行トレースログが一致しなかった特殊なケースだった

  • 6502やそれに類するコードでは、私はしばしばメモリアドレスと即値を取り違えるミスをする。#$1234のような表記法はミスを誘発しやすいと思うし、Chuck Peddleでさえこの文法を深く後悔していたという話も聞いたことがある。IDEで#を赤色で強調して、ある程度防げていた。Rareの開発者でさえこうしたミスを避けられなかった例がある

    • かなり昔、GNUアセンブラのintel_syntax noprefixモードで似た問題を経験したことがある。ここでは即値の名前付き定数を前方参照すると、メモリアドレスやシンボルとして解釈されうる文法上の曖昧さがある。その結果、予想に反してシンボルのリンク時解決まで待つ一時メモリアドレスが生成されてしまい、バグ探しが本当に苦痛だった

    • ARMのように、メモリを扱うための別個の命令が必要な命令セットは、こうした紛らわしいミスを根本的に防いでくれる

  • 私の知る限り、オープンバス現象は初期の単純な同期バスシステムでしか現れない。ほとんどの他のシステムでは、存在しないアドレスにアクセスすると全0や全1のような一定値を返し、これはバスプロトコルにおいて応答がない場合にマスター側が検知できるハンドシェイク(PCIのmaster abortなど)で処理される

  • Parallax Propellerチップをプログラミングしていたとき、同じようなミスを何度も経験した。JMP #addressJMP addressの違いをしょっちゅう混同してしまうのだが、これは6502アセンブラのmuscle memoryのせいだ。PropellerではJMP #addressは指定したアドレスへのジャンプで、JMP addressは与えられたアドレスから読んだ値へのジャンプになる。問題は、この種のバグがたまに動いてしまうことがあるので、動作が止まるまで何時間も原因探しに費やしてしまうことだ

  • オープンバスとは、データバスのラインが実際にオープンで、回路が開いた状態を意味する。CPUがマップされていない、あるいは書き込み専用のアドレスをバスに出したとき、どのハードウェアも応答しないため、バスラインは浮遊状態のままになる。つまり、ハードウェアレベルでのundefined behaviorだ。実際に何が起こるかを知るには、データバスの物理構造を見る必要がある。バスはマザーボードとカートリッジの間で信号を伝える長い導体で、薄い絶縁基板によって接地面から分離されている。この構造は一種のコンデンサとして働くので、最終的には最後の信号電圧を一定時間そのまま「保持」することになる。そのため、オープンバスでは最後に転送された値が再び読まれる効果が生じる。DKC2のようなゲームはこのオープンバス特性に無自覚に依存していることがあり、NESのコントローラシリアルポートも下位ビットしか信号を出さず上位ビットはオープンバスになるため、特定のゲームではLDA $4016命令で$40$41が期待される。オープンバス現象は、スーパーマリオワールドのクレジットワープのようなスピードラン戦略(メモリ汚染や任意コード実行)にまで応用されている。ただし、標準ではないカートリッジ、プルアップ/プルダウン抵抗の使用、あるいはDMAとの特異な相互作用(Horizontal DMAなど)は例外的な結果を生む。たとえばSNESのHDMA転送が命令の途中で発生すると、オープンバス読み出しのタイミングに影響し、Super Metroidのスピードランexploitで複製しようとするメモリブロックの間に異常な値が入り、exploitが壊れることがある。そのため、実機または非常に精密なエミュレータを使うとクラッシュが発生する一方で、ほとんどのエミュレータや公式再販版ではこうしたニッチな挙動が完全には実装されていないため、戦略が正常に動作する。Super MetroidのTAS世界記録完走もこのHDMA挙動に依存している。敵の位置を操作してCPUタイミングを変え、HDMAがオープンバスに望みの値を載せるようにして、最終的にはコントローラ入力をコードとして実行し、任意コード実行まで可能にしている Super Mario World credits warp 動画, HDMA 活用 動画, Super Metroid DMA exploit 動画, Super Metroid TAS 記録

    • Ben Eaterの6502ブレッドボードコンピュータ動画シリーズのおかげで、こうしたハードウェア挙動がどう動作するのかを理解するのに非常に役立った。商用機器でこうしたバス挙動がどのように拡張されるのかを実感できる Ben Eater サイト
  • こういう興味深いバグ解析コンテンツが好きで、アセンブリコードは60%くらいしか追えないが、一緒に添えられた文章の説明のおかげで理解が深まる。そして、長いあいだ誰にも知られなかったバグが名作ソフトウェアで明らかになる、こういうストーリーが特に面白い

    • この時代のシステムには、現代の組み込みシステムなどでは必須の、ネットワーク接続の有無にかかわらず求められる大半のチェック機能が存在しなかったので、なおさら興味深い。NES時代には数多くのread/writeが単にライン電圧をトグルするだけのもので、何が起こるかは本当にその瞬間になってみないと分からなかった。CRTブランキング信号と正確に同期したタイミングで電圧を切り替えて望む効果を得ており、スーパーマリオブラザーズ3ではRAMマルチプレクサをトグルして画面更新のタイミングごとにスプライトバンクを切り替えるといった芸当もしていた。地域ごとのテレビのNTSC/PAL差による走査レートがレンダリングロジックのクロックの役割を果たしていたため、それぞれのテレビに合わせたソフトウェアを別々に発売しなければならなかった、本当にワイルドな時代だった
  • ゲームをエミュレータで遊んでいて進行が詰まると、「もしかしてエミュレータのバグでは?」という疑いが常に湧く。この件についても、自分なら単にゲーム設計がこういう難しさにしてあるのだと思っていただろう。それに、ゲーム難易度が本当に高いときも「エミュレータのレイテンシのせいでは?」と疑うことがあり、そのため自作でMiSTer FPGAを作って使うようになった

    • Chrono Triggerには4つのキーを同時入力しなければならない場面があるが、USB入力が一度に3つまでしか送れず、4回に1回しか登録されない現象のせいで非常に難しく、苛立たしかった記憶がある

    • DKCをZSNESでしか遊んでいなかったので、記事を読むまでこれがエミュレータのバグだとはまったく知らなかった。ゲームデザインが元からそういう難しさなのだと思い込んでいて、バグだと知って本当に衝撃だった

    • 子どものころBionic Commandoをよく遊んでいたが、エミュレータでやり直したらずっと難しく感じた。後で調べたら、エミュレータのバグで敵が消えず、必要ライフが2倍になっていた。それでも一度はその状態でクリアしたことがあるが、二度とやりたくない

  • DKC 1のSGIベースのプリレンダ3Dグラフィックは当時の最先端技術だった。Mega DriveのVector Manも似た手法を使っていたが、DKCほど注目はされなかった

    • 1995年当時、DKCの主な対象年齢層(11歳)だったが、このゲームのグラフィックは本当に衝撃的だった。発売前後には販促用ビデオも受け取ったことがあり、舞台裏映像が入ったそのテープを何度も見返していた。自分ではゲームを所有していなかったが、友だちの家で遊ぶ機会があった

    • 子どものころ、DKCのグラフィックにはどこか「偽物」っぽい感覚を覚えていた。当時の雑誌は、SNESがリアルタイムで3Dキャラクターをレンダリングしているかのような作為的な説明をすることがあったが、実際にはパラパラ漫画アニメーションのような方式だと、うっすら気づいていた