🚌

SNESオープンバス理論入門

2023/04/14に公開

はじめに

初めまして、倫葉(ともは)と申します。普段はスーパーファミコン(SNES)の代表作である、スーパーマリオワールドの研究をしています。スーパーマリオワールドでは、任意コード実行を行う際に、オープンバスと呼ばれるメモリ空間を経由して、任意コードを実行します。このオープンバスはスーパーマリオワールドの研究においては非常に重要な概念である一方、界隈外にはあまり認知されていないように感じます。この記事では、65C816アセンブリの基本を習得している方向けに、オープンバスの基本を解説します。今後も、65C816やスーパーマリオワールドの内部処理などに関する記事を投稿したいと思いますので、よろしくお願いします。

オープンバス理論とは

ここで、この記事のタイトルを「オープンバス入門」ではなく、「オープンバス理論入門」としていることに注意してください。つまり、オープンバスに関する理論は公式なものではない、ということです。例えばこの後説明するMDRはレジスタの一種として考えられていますが、CPUの仕様書にそのようなレジスタは(明示的には)存在しませんし、そもそもオープンバス理論自体がリバースエンジニアリングや数々の実験によって、「確からしい」とされているに過ぎず、まだまだ未解明の問題も多い分野なのです。

この記事を通してオープンバス理論に興味を持った皆さんが、オープンバス理論の発展に寄与することを期待しています。

オープンバスとは

SNESのアドレス空間はアドレス$000000〜$FFFFFFの$1000000バイトからなり、これらはROM、RAM(SRAM・VRAMなどを含む)、そしてハードウェアレジスタ(Hardware Resister)から構成されています。例えば、RAM$7E0123に書かれている1バイトのデータを読み出す作業を考えてみてください。もし、RAM$7E0123に$01という値が格納されていたとして、その値を取り出すには、当然、LDA $0123のような命令を使えばよく、この場合はアキュムレータに$01が代入されます。これは読み取り元がROMであっても、はたまたハードウェアレジスタであっても同じで、オペコードに応じたレジスタに、オペランドで指定したアドレスに格納されている値が返されます。

LDA $0123  ; A = $01

この一連の流れをさらに詳しく考えてみましょう。あなたがLDA $0123を実行したとき、CPUはアドレスバスA(Aバス)を通ってRAM$7E0123にアクセスし、値$01を取り出してアキュムレータにそれを代入します。では、アクセスしたアドレスがROMでもRAMでもハードウェアレジスタでもなかったらどうなるのでしょうか?もっと言えば、アクセスしたアドレスが何の数値も持っていなかったらどうなるのでしょうか?

このような一見ありえない問いに答えを与えるのがオープンバス理論です。すなわち、オープンバスとは、未割り当てで、読み取りに対して本質的に何の戻り値も持たないアドレス空間であると定義することができます。SNES Spec(2017)では、「IOレジスタ(または読み込み可能なレジスタの未定義ビット)の中に読み込み操作時の戻り値が”オープンバス”」なものがある、と定義されていますが、これ(「戻り値」という説明)はどちらかといえば後述するMDRの説明であり、前半部分(「IOレジスタまたは読み込み可能なレジスタの未定義ビット」)こそがオープンバスだと言えます。当然、オープンバスは解釈によってさまざまな定義が可能ですが、先ほどの筆者の定義は、もっぱらSethBling[1](2016)による

region of the address space called open bus, which is not mapped to any device
(オープンバスと呼ばれるどんなデバイスとも接続されていないアドレス空間の領域)

という定義に基づいています。ここで、”device”とは、コントローラーやモニターといった一般的な「デバイス」(電子機器)ではなく、コンピュータの内部に存在するRAMやROMといった「モジュール」を表すことに注意してください。つまり平たく言えば、オープンバスとは、アドレスが割り当てられているものの、何の役割も持っていない領域であるとも考えることができます。

具体的にどの領域がオープンバスになるかは、ROMのタイプやゲームごとによっても異なりますが、例えばLoROMかつ特殊チップが搭載されていないスーパーマリオワールドでは、バンク$00〜$6F$80〜$EF$FEおよび$FFのアドレス$4400〜$7FFFまでがオープンバスになっています(これだけではありません)。これは、$4200〜$5FFFまでのI/Oポート領域(ハードウェアレジスタ)のうち不使用のものと、不使用である$6000〜$7FFFまでの拡張予約領域から構成されています。

MDRとは

MDRとはMemory Data Resisterの頭文字を取ったもので、W65C816プロセッサに存在すると仮定されているレジスタです。MDRは、W65C816の一般的なCPUレジスタであるアキュムレータやDBレジスタなどとは異なり、プログラマが意図して操作したり、開発の過程において意識されることはありません。MDRには、CPUが最後に読み込んだ、あるいは書き込んだ1バイトの値が格納されています。

例えば、前述のLDA $0123という命令を考えてみましょう。この命令は機械語でAD 23 01と表されます。従って、この命令でCPUに最後に読み込まれる値は$01であり、この命令を終了した時点でMDRには$01が格納されます。ところが、この命令は実行されるとただちにRAM$7E0123に格納されている値(ここでは$01)を読み取るので、MDRは即座に$01になります。

LDA #$01  ; A9 01なのでMDRは01
LDA $0123  ; MDRは$7E0123の値
STA $0100  ; MDRはアキュムレータの値

ソフトウェアを実行するにあたって、意図的にソフトウェアを終了するか、STP命令でCPUを停止させない限り、常にCPUは命令を実行し、値を読み書きし続けるので、通常MDRの値が問題になることはありません。

オープンバスへのアクセスとMDR

オープンバスからの読み取り

ところが、MDRの値が問題となるのが、CPUがオープンバスにアクセスした場合です。前述した通り、オープンバスはどのような内部機構とも繋がりがないため、オープンバス自体は戻り値を持ちません。では、戻り値を持たないオープンバスから値を読み取ろうとするとどうなるのでしょうか?その答えこそ、「MDRの値が返される」なのです。より詳しく見てみましょう。

例えば、オープンバスである[2]$005000からLDA $005000のようにして値を読み取ろうとした場合、アキュムレータにはどのような値が入るのでしょうか。この命令は機械語でAF 00 50 00なので、この命令が実行された時点で、MDRにはCPUに最後に読み込まれた$00が格納されています。$005000はオープンバスで戻り値を持たないため、MDRの値が返されます。従って、この命令はアキュムレータに$00を代入します。同様に、LDA $015000はアキュムレータに$01を代入します。従って、オープンバスから値を読み取る命令では、オペランドの最上位バイトが戻り値になると考えて問題ありません。

LDA $005000  ; A = 00
LDA $015000  ; A = 01

同様に、インダイレクトなアドレッシングモードでも、実効アドレスの最上位バイトがMDRに格納されます。

org $008000

MainLoop:
	LDA [AddressTable]  ; A = 00
	~~~
	JMP MainLoop

AddressTable:
	db $00, $50, $00

オープンバスへの書き込み

オープンバスへの書き込みは何の意味も持ちません。というのも、オープンバスは定義より、未割り当ての領域なので値を保持することができません。よって、STA $005000のような命令は実行することはできますが、オープンバスそのものには何の影響も与えません。ROMにストア命令を実行してもROMの値が変更されないのと同じです。

オープンバス領域の実行

ここで問題となるのが、オープンバス領域を命令として実行した場合の挙動です。例えば、JML $015000を実行し、プログラムカウンタ(PC、インストラクションポインタともいう)をオープンバス領域に飛ばすとどうなるのでしょうか?実はこれも、前述したオープンバスから値を読み取る場合の挙動と同じように理解できます。そのために、まず、「アセンブリにおける命令の実行とは何か」について解説します。

そもそも、JMP系命令を「実行領域のジャンプ」とか、「領域の実行」とか言うから分かりづらいのであって、命令の実行とは、プログラムカウンタが指し示すアドレスから値を読み取り続けて、その数列が命令として完成した段階でCPUが命令として実行しているに過ぎません。例えば、ROM$00ABCDに次のようなサブルーチンがあって、JSR $ABCDでそれを実行する場合を考えてみましょう。

org $00ABCD

Subroutine:
	LDA #$1C
	STA $0100
	RTS

ここで、あなたがJSR $ABCDを実行すると、CPUはアドレスバスAを通って$00ABCDにアクセスします(ここではリターンアドレスのプッシュは考えない)。$00ABCDに書かれている値は$A9(LDA)で、CPUはそれを読み取ります。しかし、$A9はそれ単体で命令とならないので、プログラムカウンタは1バイト進んで、$00ABCEから$1Cを読み取ります。ここで、CPUは「A9 1C」という2バイトの数列をLDA #$1Cという命令として認識し、アキュムレータに$1Cをロードします。同様に、CPUは$00ABCFから順に値を読んで、9C(STA) 00 01となって初めて$7E0100へのストアを実行します。$00ABD2の$60(RTS)は暗黙のオペコードなので、$60がCPUに読み込まれた時点でただちにRTSとして実行されます。

では、オープンバス領域を命令として実行した場合を考えてみましょう。JML $015000によるオープンバス領域のジャンプ命令は、プログラムカウンタをオープンバス$015000に移動させます。このとき、CPUは$015000から値を読み取ろうとしますが、そこはオープンバス領域なので、MDRの値がCPUに返されます。JML $015000は機械語で5C 00 50 01なので、MDRには$01が格納されています。よって、CPUはオペコード$01を読み込みますが、$01はそれ単体で命令にならないので、プログラムカウンタは1バイト進んで$015001から値を読み取ろうとします。しかし、当然ここもオープンバスなので、CPUにはまたもMDRの値が返されてしまいます。MDRにはまだ$01が格納されているので、CPUは$01を読み込みます。ここで、CPUには「01 01」というORA ($01,X)を表す命令が与えられたので、CPUは$015001でORAを実行することになるのです。

JML $015000
ORA ($01,X)

もう1ステップ見てみましょう。ORA ($dp,X)命令は、$dpとXレジスタの和のダイレクトページアドレスから2バイトの絶対アドレスに格納されている値とアキュムレータの値の論理和を取って、アキュムレータに演算結果をロードする命令です。ここでは、Xレジスタに$09が格納されているとして考えます。ORA ($01,X)は$01と$09の和が$0Aであることから、ORA ($0A)として認識されます。ダイレクトインダイレクトモード(($dp))は$dpで示されるアドレスとその1バイト隣のアドレスに格納されている値を実効アドレスとします。ここでは$7E000Aに$07、$7E000Bに$01が格納されているとすると、ORA ($0A)ORA $0107として認識されますね。ここで、もし$7E0107に$17が格納されていれば、ORA $0107ORA #$17と同じ命令になります。[3]

では、次にオープンバス$015002で実行される命令は何でしょうか?当然、ここではMDRに何の値が入っているかが問題となります。この例では、MDRには$17が格納されています。なぜなら、最後にCPUに読み込まれた値は$7E0107から読み取った$17だからです。

「なぜアキュムレータの値と$17の論理演算結果でないのか」と思った方がいらっしゃるかもしれません。この理由は、「オープンバス」という名前から推測することができます。つまり、アキュムレータと$17の演算はCPU内部で行われている、ということです。MDRに入る値はあくまでバスを通ってモジュールから読み取ってきた値であり、CPU内部で受け渡されたり計算された結果の値はMDRに入らないことに注意してください[4]。よって、次に実行される命令は17 17であるORA [$17],Yになります。

JML $015000
ORA ($01,X)
ORA [$17],Y

オペコード$FCのみの特性

65C816を深く学んだ読者の皆さんであればご存知の通り、JSR系の命令はリターンアドレスを保持するために、プログラムカウンタを飛ばす前にリターンアドレスをスタックにプッシュします。この特性の影響で、JSR ($addr,X)命令(オペコード$FC)は、オープンバス領域で実行した際のMDRに入る値が他の命令と異なります。

一見すると、MDRが$FCの状態でオープンバス領域を実行すると以下のような結果になると考えられます。

  1. CPUはMDRから1バイト読み取って$FCを得る
  2. FCはそれ単独で命令にならないので、プログラムカウンタは1バイト進んでさらにMDRから1バイトの$FCを得る
  3. FC FCは命令として完成していないので、さらにMDRから1バイトの$FCを読み取る
  4. FC FC FCJSR ($FCFC,X)となり、リターンアドレスがスタックにプッシュされた後にサブルーチン分岐が実行される

ところが、前述のSethBling(2016)によれば、JSR ($addr,X)命令は実際には以下のような手順に従って実行されます。

  1. CPUはMDRから1バイト読み取って$FCを得る
  2. FCはそれ単独で命令にならないので、プログラムカウンタは1バイト進んでさらにMDRから1バイトの$FCを得る(ここで得られた$FCはジャンプ先アドレスの下位バイトになる)
  3. ここで、スタックにリターンアドレスの下位バイトがプッシュされる
  4. さらに、スタックにリターンアドレスの上位バイトがプッシュされる
  5. スタックへのプッシュはアドレスバスを通して行われるので、この段階でMDRはリターンアドレスの上位バイトになる(ここでは$XXと表す)
  6. FC FCは命令として完成していないので、さらにMDRから1バイトのジャンプ先上位バイトを読み取る
  7. FC FC XXJSR ($XXFC,X)となり、サブルーチン分岐が実行される

この現象はオペコード$FC特有のものであることが分かっており、他のJSR命令には適用されません。この現象の詳細については、マシンサイクルごとの処理を理解する必要があり、基本を超えるので本記事では扱いませんし、特に暗記する必要もないと思いますが、詳しく知りたい方はRetro Game Mechanics Explained[5](2023)の記事が非常に分かりやすいです。

その他の特筆すべき事項

オープンバスおよびMDRについては他にも以下のようなことが分かっています。

  • PPUにはPPU特有のMDRが存在し、これはPPUレジスタを通じた読み込みでしか更新されない
  • このドキュメントで解説したオープンバス理論はSFC/SNES実機のみの挙動で、一部のエミュレータではMDR、およびオープンバスが実機の通り再現されていない。特に、Switch Onlineなどで用いられている、任天堂の公式エミュレータであるCanoeではオープンバス、およびMDRの再現に著しい違いがあることがSethBling et al.(2022)の研究[6]によって分かっている
  • セーブ機能が付いた特殊カートリッジ上でゲームを実行している場合は、実機ではオープンバスになっている領域がシステム記憶領域として割り当てられていて動作しない場合がある

おわりに

以上がSNESにおけるオープンバス理論の概要になります。オープンバス理論は今までスーパーマリオワールド界隈以外であまり日の目を見ることがなかった分野ですが、この記事を通して少しでも多くの方にオープンバスの面白さを知っていただければ嬉しいです。最後まで読んでいただきありがとうございました。

参考文献

https://wiki.superfamicom.org/open-bus
https://w.atwiki.jp/snesspec/pages/164.html
https://problemkaputt.de/fullsnes.htm
https://cohost.org/rgmechex/post/1242643-rgme-article-12-op
https://pastebin.com/t17QxjvX
https://docs.google.com/document/d/1my6JXPM0iHERHjsptm6453rDGIJENqTVJnK7UGuQlLo/edit

脚注
  1. SethBling氏はスーパーマリオワールドの任意コード実行研究における大家であり、筆者もSethBling氏の先行研究を応用したり、あるいは一緒に研究をしたりしています ↩︎

  2. スーパーマリオワールドにおいて ↩︎

  3. 各メモリに対応する値は実際にスーパーマリオワールドで任意コード実行を行うときに格納されている値を使っています ↩︎

  4. 当然、その結果がバスを通ってモジュールに渡された場合はMDRに格納されます ↩︎

  5. ちなみに、Retro Game Mechanics Explaninedはスーパーマリオワールド研究者のIsoFrieze氏が運営していて、同氏はモデレータやプラクティスロムの開発など、界隈に多大な貢献をしている人物です ↩︎

  6. 筆者も共同研究者だったりします ↩︎

Discussion