1 ポイント 投稿者 GN⁺ 2025-07-02 | 1件のコメント | WhatsAppで共有
  • Donkey Kong Country 2の一部の回転バレル区間には、古いSNESエミュレーター ZSNES で方向キーを離しても回り続けるバグがあり、その原因は open bus 動作の未実装にある
  • 実機SNESでは、マップされていないアドレスを読むと最後のデータバス値を再度読み取り、DKC2はbank $B3の $2000/$2001 読み取りでこの特性に依存している
  • 問題のルーチンはバレルの直前の方向と新しい方向をXORしたあと and $2000 を実行し、実機ではこの16ビットのopen bus読み取りが 0x2020 を返すことで方向境界の通過を判定する
  • ZSNESのようにopen bus読み取りが0だとAND結果が常に0になり、回転終了の分岐が実行されないため、反対方向を押すまでバレルが回り続ける
  • この命令は and #$2000 であるべきだった可能性が高く、あるROMリビジョンでは $33EDAC のopcodeを 0x2D から 0x29 に変えると、open busなしでも正常に動作する

ZSNESで回転バレルが止まらない現象

  • Donkey Kong Country 2には、一部ステージの 回転バレル がZSNESで正しく動作しない古いバグがある
  • 正常動作では、プレイヤーはバレルの中で左右方向キーを押している間だけ回転を制御できる
  • ZSNESでは左右を一度押すとバレルがその方向へ回り続け、反対方向を押すと逆方向へ回り続ける
  • 後半の区間ではバレルがトゲや危険物の上に配置されているため、このバグによってステージ難度が開発意図より大きく上がる
  • 同じ問題はAnomieが約20年前に発見し、Snes9xで修正したものと思われるが、当時の修正はopen bus全体のエミュレーションではなく、ゲームが依存する特定アドレスの値をハードコードする方式だった
  • ZSNESではこのバグは最後まで修正されず、プロジェクトの最終リリースは 2007年 である

SNESのopen busと65816アドレッシング

  • SNESでは、誤ったメモリアドレスを読んでも通常はプログラムがクラッシュしない
  • マップされていないアドレスを読むと、CPUがデータバス上に最後に載っていた値を再度読む open bus 動作が発生する
  • SNESのメインCPUは 65C816、すなわち65816で、Ricoh 5A22 S-CPUパッケージに含まれている
  • 65816は6502の16ビット拡張CPUで、SNESでは24ビットアドレスバスを使うが、ほとんどのアドレスは8ビットのbankと16ビットのoffsetの組み合わせで作られる
  • 多くの命令は内部的に16ビットアドレスを使用し、instruction fetchにはprogram bank register、データアクセスにはdata bank registerが関与する
  • DKC2の回転バレル状態ではbank $B3の $2000と$2001 を読み取るが、これらのアドレスはbank $B3のどこにもマップされていないためopen bus読み取りになる

問題ルーチンのメモリアクセス

  • 回転バレル内で左右方向キーを離すと、毎フレーム実行されるルーチンがopen bus読み取りを行う
  • 初期状態はdata bank registerが $B3、direct pageが $0000、M/Xフラグがともにclearで、レジスタとメモリアクセスは16ビットである
  • このルーチンは $0000-$2001 範囲の複数のアドレスを使用する
    • $0EE6: 現在のバレル方向
    • $0E0A: 1フレームあたりの回転量
    • $0032: 一時変数と思われる位置
  • bank $00-$3Fと$80-$BFでは $0000-$1FFF がコンソールの128KB WRAMの最初の8KBにマップされる
  • $2000-$20FF はマップされていない領域なので、and $2000 命令は open bus読み取り になる

0x2020が回転終了判定に使われる仕組み

  • ルーチンは現在方向に回転量を足して新しい方向を作り、これを一時変数に保存する
  • 続いて直前の方向と新しい方向をXORし、その結果に and $2000 を適用して、結果が0かどうかを確認する
  • 実機SNESハードウェアでの16ビットopen bus読み取り and $2000 は常に 0x2020 を返す
    • and $2000 の機械語は 2D 00 20 である
    • 65816は8ビットデータバスを使うため、16ビット読み取りを2回の8ビット読み取りとして実行する
    • この場合、最後に読まれたバイトがアドレスのhigh byteである 0x20 なので、2回とも 0x20 が返る
  • したがって実際の動作は and #$2020 と機能的に同じである
  • AND結果が0なら新しい方向をそのまま保存し、次のフレームでも回転を続ける
  • 0でなければ回転量を0にし、新しい方向に 0x1000 を足したあと 0xE000 でマスクして、最も近い 0x2000 の倍数方向に整列させる

なぜZSNESでは回り続けるのか

  • バレル方向値は、0x0000 が下方向、0x4000 が左方向を指すようなスケールだと思われる
  • 分析されたバレルは時計回りの回転量が 0x0300、反時計回りの回転量が 0xFD00 で、360度回転に85フレーム強かかる
  • この値は60fps基準で1.5秒よりわずかに短い
  • 0x2020 とのANDで実際に意味を持つ変化はbit 13の 0x2000 である
  • 方向値における 0x2000 の変化は、基本方向または斜め方向の1段階の変化に相当する
  • 正常なハードウェアでは、方向キーを離すとバレルは次の基本/斜め方向まで回転したあと、正確にその方向を向いて止まる
  • open bus読み取りが常に0を返すとAND結果も常に0になり、回転終了コードが実行されない

タイポの可能性とROMパッチの結果

  • and $2000 はabsolute addressing命令であり、論理的には and #$2000 のimmediate addressingであるべきだった可能性が高い
  • 実機ハードウェアではopen busが0x2020を返すため、and $2000 が偶然にも意図どおりに動作する
  • この偶然の動作は、1フレームあたりの回転量の下位6ビットが常に0であるとき、0x2020 が機能的に 0x2000 と同等になる条件に依存している
  • 誤ったopcodeはbank $B3 offset $EDAC で実行され、分析されたリビジョンではゲームの4MB ROM内の $33EDAC にマップされる
  • このバイトを 0x2D から 0x29 に変えると、open bus読み取りが常に0を返しても回転バレルは正常に動作する
  • 正確なROM位置はゲームの別リビジョンでは異なる可能性がある
  • 古いZSNESを除けば、ほぼすべてのSNESエミュレーターでゲームは正常に動作するため、この事例は実用的なパッチというより、ハードウェア依存動作を追跡した分析に近い

1件のコメント

 
GN⁺ 2025-07-02
Hacker News のコメント
  • 6502 アセンブリを書いていると、即値の前の # を付け忘れてメモリアクセスしてしまう、というようなミスで人生の多くの時間を無駄にしてきた
    この例のように、偶然一部の状況では動いてしまうことも多い。さらに悪いのは、フローティングバスよりも初期化されていない RAMに依存してしまう場合で、DRAM の特性上、自分のマシンやエミュレータでは常にうまく動くのに、別の DRAM チップを使った他人のマシンでは壊れることがある。たいていデモパーティーで発表 15 分前に、会場のマシンで動かないことでそれに気づく

    • 6502 CPU と動的メモリを組み合わせて使ったアーキテクチャがあったのか気になる。限られた経験では、そのプラットフォームはいつもスタティック RAM を使っていたように思う
    • 6502 が初めてのアセンブリ言語で、LDA #2 は「A に数値 2 をロード」、LDA 2 は「メモリ位置 2 にある値を A にロード」というふうに、常に区別して考えていた
    • こういう状況では、コードを LLM に入れてみるのが実際に役立つことがある。こうしたタイプミスやミスは影響が大きいのに人間の目ではあまりに見落としやすく、LLM はそういうものをかなりうまく見つける
  • タイトルの Open Bus が大文字で書かれていたので、最初は聞いたことのない古いバスプロトコルか標準の固有名詞だと思って読み始めた
    読んでみると、アドレス線デコーダが指定アドレス $2000 でどのメモリデバイスも有効化せず、バスが何にも接続されていない「開いた」状態という意味だった。即時アドレッシングの # を付け忘れた問題が、古いエミュレータが実機のようにメモリ読み出しを処理しないことで初めて表面化したのはなかなか面白い。修正として絶対アドレッシングではなく即時アドレッシングを使えばメモリ読み出しをしないので、実行時間も速くなるはず。そのコード片ではおよそ 2µs ほど速くなるかもしれないが、おそらくベアメタルでしか意味がなく、エミュレータはいずれにせよ完全なタイミング精度を備えてはいない可能性が高い

    • Rare には、テストではうまく動いていたのに、数年後に新しいアーキテクチャで初めて隠れたバグが露呈するゲームの例がいくつもある。他社にはないという意味ではなく、この話題では Rare が参照しやすい名前というだけ
      Donkey Kong 64 にはメモリリークがあり、当時の基準では非現実的に長い連続プレイ時間である 8〜9 時間後にゲームが落ちるものだったと理解している。開発中には見つからなかったが、エミュレータのセーブステートを使い、ゲーム内のセーブ機能の代わりに進め続けると、すぐに積み上がる時間でもある。ただし経緯は曖昧。ある資料では、Memory Pak の同梱はクラッシュまでの時間を 8〜9 時間から 13〜20 時間へ先延ばししてバグを隠そうとした土壇場の対応だったと主張しているが、最近の研究では偶然であり、Rare や Nintendo がそのバグを認識したうえで発売したわけではない、という見方のようだ
    • 一部の SNES エミュレータは、この時点で事実上タイミングまでほぼ完璧になっている [0]。それでも 2µs は、例外的な場合でなければ体感できる差にはならないはず
      [0] https://arstechnica.com/gaming/2021/06/how-snes-emulators-go...
  • RetroArch の RunAhead 機能に取り組んでいて、セーブステートが一致しない時点を確認していたとき、SNES 版 Puyo Puyo が PPU オープンバスを使っているのを見たことがある
    ステートをロードした後、PPU Open Bus から読み出した値が変わり、CPU 実行トレースログが一致しなかった

  • 6502 系のミスをいつもするわけではないが、やるときはたいてい即値の代わりにメモリアドレスを書いてしまうミスだ
    非常によくある、犯しやすいミスで、Chuck Peddle 本人も即値に #$1234 のように # を付ける構文を深く後悔していたと信じている。IDE で # を明るい赤色に見えるようにしたら、少しは助けになった。Rare のアセンブリの神々も同じ問題にやられていた

    • かなり前に GNU アセンブラの intel_syntax noprefix モードで似た問題に遭遇した
      即値とメモリアドレスの両方を受け取れる命令で、前方参照される名前付き定数の即値を、未知のシンボル参照として解釈できてしまう構文上の曖昧さがあった。結果として、期待した即値ではなく、リンク時にシンボルの再配置アドレスで埋められるプレースホルダのメモリアドレスを持つ命令としてアセンブルされた。デバッグはつらかった
    • ARM のような命令セットでは、そういうミスは事実上不可能になっている。メモリを扱うときは別の命令を使う必要がある
  • こういう記事が好きだ。アセンブリコードはいつも 60% くらいしか追えていない感じなので、横に付いている散文での説明が本当に助けになる
    古典的なソフトウェアの中に、誰も理解していなかった、あるいはもしかすると今まで気づかれてすらいなかったバグの話を聞くのも面白い

    • この時代のシステムが魅力的な理由の一つは、今ではほぼどこでも当たり前と見なされている現代的な検査装置がなかったことだ
      ネットワークに接続され得るシステムには必須で、完全に孤立した組み込みアーキテクチャにも入れられるほど安くなった装置のことだ。もともとの NES では、多くの読み書きはどこかの線の電圧をトグルするだけで、その後は起こるように起こった。望む効果は、CRT のブランキング期間を示す信号に正確に合わせて、非常に制御された方法で電圧をトグルすることで得ていた。Super Mario Bros 3 の一部のアニメーションは、RAM マルチプレクサをトグルして複数のスプライトデータバンクのうち一つを選ばせ、グラフィックハードウェアがスプライトを取りに行くとき、見た目が少し違うまったく別のチップから読み出すようにしていた。テレビのタイミングが重要だったため、NTSC と PAL のテレビ地域向けソフトウェアも別々に出す必要があり、両方式は走査周波数が異なり、その走査周波数がレンダリングロジックを駆動するクロックだった。本当に荒々しい時代だった
  • 自分の知る限り、オープンバスは単純な同期式バスを使っていた初期のシステムでしか見られない
    他の大半のシステムでは、存在しないアドレスにアクセスすると、0 だけで埋まった値か 1 だけで埋まった定数値を返すものだと思っている。バスプロトコルには応答がないことをマスターが知るためのハンドシェイクがあるからで、PCI の用語では “master abort” に相当する

  • オープンバスとは、文字どおりデータバス線が開放回路になっているという意味です。
    CPU がマッピングされていない、あるいは書き込み専用のアドレスをアドレスバスに載せたものの、バス上のどのハードウェアも応答しなかったため、バス線が駆動されず浮いた状態になります。名目上はハードウェアレベルの未定義動作です。
    実際に何が起きるのかを理解するには、データバスの物理構造をもう少し見る必要があります。マザーボードやカートリッジ周辺に信号を運ぶ長い導体があり、薄い絶縁基板層を挟んでグランド面から離れています。これはコンデンサのように見え、実際にエンジニアはこれを寄生容量として説明・モデル化します。この効果はバスの最大データ転送速度を制限するため、最小化しようとされます。しかしこの効果のため、バスが駆動されていないときは、最後に駆動されていた電圧にとどまろうとする傾向があります。小さな DRAM セルのように動作し、記事で述べられている「オープンバス読み出しは、最後にバスを通った値を返す」という効果が生じます。
    DKC2 のように、ゲームが誤ってオープンバス効果に依存してしまうことは珍しくありません。NES では、コントローラー接続用のシリアルポートレジスタは下位ビットだけを駆動し、上位ビットはオープンバスです。LDA $4016 命令でコントローラー入力を読み、値 $40$41 を期待するゲームもいくつかあります。ここでの 4 は、オープンバスのために残っている値です。
    メモリ破壊や任意コード実行エクスプロイトの一部として、オープンバス動作に依存するスピードラン戦略もあります。たとえば Super Mario World のクレジットワープは、プログラムカウンタをマッピングされていないメモリへ送り込んでしばらく進ませ、最終的に RAM に到達させて、敵の位置を精密に操作して作ったペイロードを実行します [1]。
    ただし、一般には予測可能なオープンバス動作にも例外があります。非標準のカートリッジは、マッピングされていないメモリにデフォルト値を返したり、プルアップ/プルダウン抵抗を含んでいてオープンバス動作に影響を与えたりすることがあります。DMA との興味深い相互作用もあります。SNES は HDMA という機能をサポートしており、アプリケーションはフレームの途中でデータをアップロードしたり設定を変えたりするために、CPU からグラフィックハードウェアへ正確なタイミングでデータを転送するよう予約できます [2]。この DMA 転送は、転送のためにバスを使うべく CPU を一時停止させます。そして DMA 転送が命令実行の途中、つまり対象アドレスを読んだ後で実際のオープンバス読み出しの前に割り込むと、オープンバス読み出しの動作を変えることがあります。
    この非常に特殊な境界ケースは、Super Metroid のスピードラン用エクスプロイト [3] に大きな影響を与えます。このエクスプロイトは範囲外の memcpy を発生させ、オープンバスから RAM へ大きなデータブロックを移そうとします。オープンバス読み出しはほぼ常に 0 を返します。関連するロード命令の最後のバイトが 0 だからです。しかし HDMA によるグラフィック効果が多い特定の部屋では、DMA 転送がその読み出しのいずれかに影響する可能性がかなりあり、そうなると 0 ではないバイトが重要な位置に入り込み、エクスプロイトが正常に動作せずクラッシュします。このためコミュニティ内でちょっとした議論が起きました。一部のルートや戦略は、エミュレータや非標準ファームウェアでしか安定しません。実機や非常に正確なエミュレータを使うプレイヤーはクラッシュに遭遇する可能性が高い一方で、任天堂のすべての公式再リリース版を含む大半のエミュレータは、命令途中の HDMA 転送がオープンバス読み出し値を変えるという、この特殊な境界ケースをエミュレートしていません。
    また、現在最速の Super Metroid TAS クリア [4] は、この HDMA 相互作用に依存しています。オープンバスを実行しようとしてクラッシュする状況は見つかったものの、通常は有用に制御できませんでした。部屋内の敵を操作して CPU タイミングに影響を与え、HDMA によって適切なタイミングで有用な命令をバスに載せることができました。最終的には、コンソールにコントローラー入力をコードとして実行させ、完全な任意コード実行を達成しました。
    [1]: https://youtu.be/vAHXK2wut_I
    [2]: https://youtu.be/K7gWmdgXPgk
    [3]: https://youtu.be/CnThmKhtfOs
    [4]: https://tasvideos.org/8214S

    • データバスの物理構造を見る必要がある、というくだりで、もう一度 Ben Eater を称賛したいです。
      6502 でブレッドボードコンピュータを作る彼の動画シリーズのおかげで、記事の内容とハードウェア上の問題説明が実際に理解できました。もちろん、彼の基本的なバスの例を商用機へ拡張して考えたものです。そうでなければ、ほとんど分からなかったでしょう。
      https://eater.net
  • Parallax Propeller チップをプログラミングするときにも、ある程度似た問題があります。
    指定したメモリ位置へジャンプする JMP #address を使うべきなのに、いつも指定したメモリ位置から読んだアドレスへジャンプする JMP address を使ってしまいます。6502 アセンブラがまだ筋肉の記憶に残っているからのようです。
    Propeller: JMP #address
    6502: JMP address
    Propeller: JMP address
    6502: JMP (address)
    最悪なのは、この記事のように、バグのある Propeller コードが時には動いてしまう点です。そしてある瞬間に止まり、なぜそうなるのか突き止めるのに何時間も費やすことになります。

  • DKC 1 の SGI によるプリレンダリング 3D グラフィックは、当時の最先端でした。Genesis の Vector Man も似たことをしていましたが、あまり注目されませんでした。

    • 1995年ごろ、DKC のまさにターゲット層だった 11 歳の子どもで、あのゲームは本当に衝撃的でした。
      見ているものを信じられませんでした。発売前後にゲームを予告し、開発の舞台裏も見せるビデオテープがありました。たぶんシリアルの箱のようなところから申し込んでもらった販促物だったと記憶しています。そのテープを本当に何度も見ました。DKC を自分で所有することはできませんでしたが、友人の家でプレイすることはできました。
    • 子どものころ、そのグラフィックはどこか偽物っぽく感じられました。あるレベルでは、それが「本物」ではないと感じ取れていたからです。
      当時の雑誌はこの話題についてかなり曖昧に書き、SNES の性能でキャラクターなどをリアルタイムにレンダリングしているかのように示唆することがよくありました。実際には、本質的にはフリップブックアニメーションに近かったにもかかわらずです。
  • エミュレーションでゲームをしていて行き詰まると、結局エミュレーターのバグなのか気になってしまうことがある
    この特定の問題なら、ゲームがもともとこう設計されていて、ただ難しいだけだと思っていたはず。あまり直接関係はないが、ゲームが本当に難しく感じるときにも似たように「これはエミュレーションの遅延のせいなのか?」と思う。この問題をきっかけに深く掘り下げていき、結局 MiSTer FPGA を自分で作った

    • Chrono Trigger にもこういうのがあった
      ネズミを捕まえたあと、4つのキーを同時に入力しなければならない箇所があったと記憶している。ところが USB 入力は一度に3つまでしか伝えないので、通過するには4つのキーを連打して、ごく短い時間内に最終的に全部登録されるようにする必要があった。何度も試す必要があり、とてももどかしかった
    • DKC は ZSNES でしか遊んだことがなく、記事を読むまでこれがエミュレーターのバグだとはまったく知らなかった
      言われているように、バレルから発射するタイミングを合わせて正しい角度を作るのが、意図されたゲーム設計なのだと普通に思っていた。バグだったと知って本当に驚いた
    • 子どものころ Bionic Commando をよく遊んでいた
      2000年代初頭にエミュレーターで動かしてみたら、記憶よりずっと難しかった。あとで見ると、基地を爆破しても敵が消えないエミュレーションのバグがあり、Ladd は相変わらず動けないままだった。そのためレベルをクリアするには、体力がだいたい2目盛り余分に必要だった。可能かどうか試すために一度はその状態でクリアしたが、二度とやらなかった