マリオワールドをハッキングする
はじめに
こんにちは、倫葉(ともは)と申します。普段はスーパーファミコン(SNES)の代表作である、スーパーマリオワールドのRTAにおける任意コード実行と呼ばれる分野の研究をしています。いきなり私事で大変恐縮ですが、2023年6月17日〜18日に北海道で開催されるRTAイベント「Sapppro Offline Speedrun 2023」さんに、スーパーマリオワールドの11 Exit ACEというカテゴリーで採用をいただき、はじめてオフラインのRTAイベントに出場することになりました。
今回の記事では、こちらのイベントで走らせていただく11 Exit ACEというカテゴリーがどのようなカテゴリーなのかの説明を通して、スーパーマリオワールドをハッキングする方法について解説したいと思います。なお、11 Exit ACEの具体的なチャートには興味がなく、スーパーマリオワールドをハッキングできる仕組みだけを知りたい方は、章「Intended RAMの定義」までをお読みいただければ大丈夫です。また、この記事は65C816アセンブリが分からない方でも理解できるように書いていますが、一部理解している方向けの解説も混在しています。この記事の対象読者は、「ROMとRAMが何かわかっている人」です。11 Exit ACEとは
技術面とはあまり関係がなく、技術記事には不要かもしれませんが、一応今後の説明のために、今回解説する11 Exit ACEというRTAカテゴリーがどのようなものかご紹介します。
そもそもこのカテゴリーは、スーパーマリオワールドRTAの中にある「11 Exit」というカテゴリーの、非公式なチャートです。RTAにあまり馴染みがない方のために説明すると、一般的に「ゲームを早くクリアする」ことを指すRTAにも、「カテゴリー」と呼ばれるさまざまなレギュレーションが存在します。例えば、方法は問わず、最速のゴールを目指す「Any%」や、全てのコースをクリアが必要な「100%」など、様々なカテゴリーがあります。スーパーマリオワールドにおける「11 Exit」とは、クッパ城までの最短経路である11個のコースを通ってクッパを倒し、ゲームをクリアするカテゴリーです。11 Exitのルールでは任意コード実行の利用は禁止されていますが(後の章で詳しく説明します)、もしも任意コード実行を認めたらどれだけ速くゲームクリアできるのか……という興味から生まれたのが11 Exit ACEなのです。
マリオワールドにおける任意コード実行
スーパーマリオワールド(以降は単にマリオワールドといいます)のRTAにおける任意コード実行は、「狭義の任意コード実行(Arbitrary Code Execution, ACE)」と「乗っ取り(Code Injection)」の2種類に分けられます。前者の例として、本来はヨッシーが食べることのできないスプライトをヨッシーに食べさせることで任意のコードを実行してエンディングを召喚する「0 Exit」や、同様の方法でコース内にあるワープ土管の出口を変える「In the 6 ACE」が挙げられます。これら狭義の任意コード実行は実行が一回のみであるため、任意コードで行う操作はRAMの書き換えや実行位置(Program Counter, Instruction Pointer)のジャンプがほとんどです。狭義の任意コード実行は実行が簡単であることや、(RTA的には)短い時間で実行可能である点を考えれば、一時的なメモリ状態の書き換えには非常に有効ですが、恒常的に任意コードを実行し続けることは不可能です。
そこで、Code Injectionと呼ばれる手法が登場します。この手法はSethBling, p4plus2とMrCheezeというマリオワールドの任意コード実行に多大な功績がある研究者3名が開発したもので、今回ご紹介する11 Exit ACEだけではなく、Hex EditorやFlappy Birdといった大規模な任意コード実行の技術的基盤として用いられています。Code Injectionでは狭義の任意コード実行と異なり、任意コードを毎フレーム実行できるため、恒常的かつ大規模な任意コード実行に適しています。
Intended RAMの定義
結論から言ってしまうと、マリオワールドにおけるCode Injectionとは「Intended RAMと呼ばれる領域の書き換え」です。したがって、マリオワールドを乗っ取るメカニズムを理解するにはIntended RAM(意図されたRAM)について知る必要があります。
ROMとRAMの違いが分かっている読者の皆さんであればご存知の通り、ゲームのプログラムはROMに記述されており、書き換えることができません。一方、RAMは(誤解を恐れずに言えば)高級言語における変数のようなもので、値を保管して各種用途に用いています。例えば、マリオの残機数を保管するRAMを1バイト確保し、その値を参照することで画面上の残機表示やゲームオーバー判定を行なっています。より詳しい話をすると、ROMはバンク$00〜$7dのアドレス$8000
〜$ffff
に書き込まれており、RAM(ここではWRAMを指す)はバンク$7eおよび$7fのアドレス$0000
〜$ffff
に用意されています。ROMとRAMは、基本的に「ROMからRAMを参照する」という、いわば主従関係にあります。
lda $0123 ; RAM$7e01234の値をアキュムレータに代入
sta $abcd ; アキュムレータの値(RAM$7E0123の値)をRAM$7eabcdに書き込む
lda #$00 ; アキュムレータに即値$00を代入
sta $0123 ; RAM$7e0123にアキュムレータの値($00)を代入
ただし、スーパーマリオワールドには例外があります。それがIntended RAMと呼ばれる領域です。speedrun.com[1]の11 Exitにおけるカテゴリールールには以下のような記載があります。
- No Arbitrary Code Execution*
- No Credits Warp
*More specifically, no letting the instruction pointer hit anywhere but ROM, open bus, and the intended RAM area ($7F8000-$7F8182), and no modifying the intended RAM area.
- 任意コード実行の禁止*
- エンディング召喚の禁止
*詳細には、インストラクションポインタがROM、オープンバス、およびIntended RAM領域($7F8000-$7F8182)の領域以外を参照してはならず、Intended RAM領域を書き換えてはいけない。(筆者意訳)
以上のカテゴリールールから、Intended RAMについて2つのことがわかると思います。1つ目は「Intended RAM領域はアドレス$7f8000
〜$7f8182
の領域を指す」こと。もうひとつは「Intended RAM領域は命令として実行される」ことです。アドレス$7f8000
〜$7f8182
は前述のSNESにおけるメモリマッピングより、RAM領域に属します。しかし、Intended RAMはメインループからの呼び出しにより毎フレーム[2]、サブルーチン[3]として実行されます。
これがIntended RAMの面白いところです。命令はROMに記述され、RAMは値の保管や参照にのみ用いるという原則を破り、RAMにアセンブリ命令を記述し、それをサブルーチンとして実行しているのです。したがって、Intended RAMに任意コードを記述すれば、そのコードは毎フレーム実行されることになります。例えば、Intended RAMに制限時間=999秒とするコードを書けば、そのコードは毎フレーム実行され、常に制限時間を管理するメモリに999秒が代入されるので、時間切れでマリオが死ぬことがなくなります。これがマリオワールドをハッキングする仕組みです。
しかし、なぜIntended RAMだけはROMに書かれず、RAMに書かれているのでしょうか?また、どのようにIntended RAMはセットアップされているのでしょうか?Intended RAMではどのような処理を行なっているのでしょうか?これらの疑問について次の章で解説します。
Intended RAMの役割
Intended RAMの詳しい解説についてはゆにるユニ(2022)のスライドが非常に詳しく、解説も分かりやすいのでおすすめですが、ここでも簡単に説明しておきます。
突然ですが、ここで、実際にマリオワールドがIntended RAM領域に命令列を記述するための命令を見てみましょう。
org $008034
; ここで、各レジスタは16bitモード
lda #$f0a9
sta $7f8000
ldx #$017d
ldy #$03fd
lda #$008d
sta $7f8002,x
tya
sta $7f8003,x
sbc #$0004
tay
dex
dex
dex
bpl $8034
sep #$30
lda #$6b
sta $7f8182
詳しい説明は省きますが、ROM$008034
からROM$008051
に記述された30バイトからなるこのコードはマリオワールドの起動処理に組み込まれていて、リセット時やハード起動時に1回実行されます。この一連のコードをブートストラップ(Bootstrap)と呼びます。
次に、このブートストラップによって記述される命令を見てみましょう。
lda #$f0
sta $0201
sta $0205
sta $0209
; ...
; 中略
; ...
sta $03f9
sta $03fd
rtl
ブートストラップによって、以上の命令列がRAM$7f8000
からRAM$7f8182
までの387バイトに記述されます。見ていただけると分かると思いますが、RAM$7f8002
から続くsta
命令のオペランド[4]は$0201から始まって、4ずつ増える等差数列になっています。したがって、ループ処理(ここではldx
-dex
-bpl
文)によって簡単に命令が記述できることが分かると思います。
高級言語で書くと……
C#で書いてみた例
void Main(string[] args)
{
byte[] intended = new byte[387];
int low = 0x01;
byte high = 0x02;
intended[0] = 0xa9; // lda
intended[1] = 0xf0; // #$f0
for (int i = 2; i < 386; i += 3)
{
intended[i] = 0x8d; // sta
intended[i + 1] = Convert.ToByte(low);
intended[i + 2] = high;
low += 4;
if (low > 0xff)
{
low -= 0x100;
high++;
}
}
intended[386] = 0x6b; // rtl
}
Swiftで書いてみた例
var intended: [Int] = []
var low: Int = 0x01
var high: Int = 0x02
intended.append(contentsOf: [0xa9, 0x02]) // lda #$f0
for _ in 1...128 {
intended.append(0x8d) // sta
intended.append(contentsOf: [low, high])
low += 4
if low > 0xff {
low -= 0x100
high += 1
}
}
intended.append(0x6b) // rtl
以上の命令を単純にROMに書く場合とブートストラップを用いてRAMに記述する場合とでは、ROMの記述量が357バイトも異なります。よって、わざわざRAMに命令列を記述している理由は、ROMの使用量を削減するためだと分かりますね[5]。
このIntended RAMはOAMテーブルと呼ばれるグラフィック描画に関わるメモリをクリアする処理を行なっています[6]。だからIntended RAMの拡張が終わった後は描画が乱れているのです。
実行している任意コード
11 Exit ACEでは、Intended RAMを拡張して以下のコードを毎フレーム実行しています。
org $7f8001 ; 本当はRAMにorgはできないけど分かりやすいように書いている
.db $04
ldx $421d
sta $1433,x
1行目の.db $04
はIntended RAMの最初の命令、lda #$f0
のオペランドの書き換えです。これによってIntended RAMの最初の命令はlda #$04
になります。
2行目ではXレジスタにI/Oレジスタ$421d
の値を代入しています。I/Oレジスタ$421d
はコントローラー3(マルチタップポート1-スロット2)の上位バイトを示すハードウェアレジスタで、コントローラーの入力によって値が変動します。
3行目では、アドレス$1433
に2行目で格納したXレジスタの値をインデックスしたメモリに、1行目で代入したアキュムレータの値($04)を代入しています。
ここからは、11 Exit ACEで用いる強制ゴール3種類でそれぞれどのような処理が行われているかを見ていきます。
強制ゴールとは
マリオワールドには、「通常ゴール」「鍵ゴール」「クッパ撃破」の3種類のゴール方法があります。特に、通常ゴールと鍵ゴールではコースクリア後に進める道が変わるため、11 Exitのチャートに適合するよう、それぞれのコースで適切なゴール方法を選択できるようにしなければなりません。
強制通常ゴール
強制通常ゴールを行うには、コントローラー3のSelectボタンとYボタンを同時に押下します。この2つのボタンを押すと、ldx $421d
命令により、Xレジスタに$60が代入されます。sta $1433,x
命令では、Xレジスタに$60が格納されているので、$1433に$60をインデックスしたアドレスのRAM$7e1493
にアキュムレータの値が代入されます。アキュムレータには、Intended RAMに元々書き込まれていたlda
命令に、任意コード実行でオペランドを$04
に書き換えたlda #$04
命令により、$04が格納されています。よって、RAM$7e1493
に$04が代入されます。
SMW Centralによれば、RAM$7e1493
は"End level timer"(コース終了タイマー)で、このメモリに適当な値を設定するとその値からカウントダウンが始まり、値が$ff(= $00 - $01)になったときに通常ゴールとなります。したがって、このメモリに$04を代入することで、コースクリア処理をタイマー経由で呼び出しているのです。
強制鍵ゴール
強制鍵ゴールを行うには、コントローラー3の右ボタンを押下し、その後右ボタンを離します。右ボタンを押すと、先ほどと同様の処理によってRAM$7e1434
に$04が代入されます。
SMW Centralによれば、RAM$7e1434
は鍵ゴール時の鍵エフェクトが現れ続けるためのタイマーで、このメモリに$04を代入し、その後右ボタンを離して$00とすることで、鍵を出現→消すことができ、強制鍵ゴールができます。
強制クッパ撃破
強制的にクッパ戦をクリアするには、コントローラー3のSelectボタン、Startボタン、Yボタン、Bボタン、および左ボタンを同時に押下します。これら2つのボタンを押すと、先ほどと同様の処理によってRAM$7e1525
に$04が代入されます。詳しいことは筆者もよくわかりませんが、このRAMに$04が代入されると、クッパを撃破できるようです。
おわりに
以上がスーパーマリオワールドにおける11 Exit ACEの技術的な解説になります。実際の11 Exit ACEでは、マリオワールドで用いられる様々な任意コード実行のテクニック(RLX Item Swapや、Invalid Powerup StatusによるMushroom ACEなど)を駆使し、Intended RAMに任意コード実行を書き込みます。非常に見どころが多く、成功したときのインパクトも大きいカテゴリーなので、ぜひ一度ご覧になってみてください。ありがとうございました。
参考文献
-
RTAの記録・順位を登録するためのサイト ↩︎
-
厳密には、メインループからゲームモードが参照され、ゲームモードごとに毎フレーム実行されるサブルーチンの中で呼び出されている。したがって、いくつかのゲームモードやスプライトロックフラグセット時など特定の条件下では実行されない ↩︎
-
処理を分割して別の場所に記述すること。高級言語でいうメソッドや関数のようなもの ↩︎
-
ここでは
sta
などのアルファベット3文字の右側という程度の認識で大丈夫です ↩︎ -
後から考えればマリオワールド内のROMには387バイト以上のまとまった不使用領域があるのですが、どれだけROMを使用するか不透明な開発段階ではブートストラップを使う方が良い選択肢だったのだろうと思います ↩︎
-
ここで、
stz
ではなく$f0を代入していることに注意してください。厳密には座標データを画面外へ飛ばすことでクリア処理を行なっています ↩︎
Discussion