🚪

マリオワールドで256回エリア移動をするとバグる理由

に公開

はじめに

こんにちは、スーパーマリオワールド研究者の倫葉(ともは)と申します。もともとは、スーパーマリオワールドにおける任意コード実行のカテゴリを中心にRTA走者として活動していましたが、現在は研究と実況解説を中心に活動しています。過去には、国際研究チームに参加し、長年未解決だったSwitch Onlineにおける「オープンバス」という領域における挙動の解明を行った実績があります。

さて、先日、何気なくYouTubeを見ていたところ、とあるショート動画を見つけました。

https://www.youtube.com/shorts/mlNKIVZiY1c

こちらの動画では、「スーパーマリオワールドのコース内において、256回エリア移動をすると、移動先がおかしくなる」というバグが紹介されていました。また、以下のような疑問点を「謎」として提示されていました。

  • マリオワールドで、エリア移動した回数はゲーム内で保持されているらしいが、なぜ回数が保持されているのか
  • なぜ256回目のエリア移動で不正な場所に移動してしまうのか

(本記事では、『伝説のスターブロブ2』様の上記動画より、一部内容を引用させていただいております。ぜひ動画の方もご覧ください)

筆者はスーパーマリオワールドのバグを中心に研究を行っているのですが、お恥ずかしながら、この動画を視聴するまで当該のバグについて知りませんでした(おそらく研究対象としているバグの種類と違うためだと思われますが)。しかしながら、マリオワールドの「謎」が出てきたら、それを解明したくなるのが研究者の定めというものです。

今回は、このバグが発生する原因について、そして、動画の投稿者さまが提示されていた2つの「謎」について、スーパーマリオワールドのプログラムから解析を行い、回答を示します。

なお、筆者のYouTubeチャンネルにて、プログラミングに詳しくない方でもこのバグの原理を理解できるような解説動画をアップロードする予定です。本記事では、技術記事として、65C816アセンブリによるプログラムの紹介を通して、本バグの原因について迫っていきたいと思います。

移動回数は本当に保存されているのか

動画のコメント欄を見ると、多くの方が、本バグの原因についてさまざまな考察を行っておられました。その考察は、大きく以下のような内容に分類できます。

  • 移動回数を保持するメモリが1byteで保存できる値を超えた結果、エリア移動の移動先を示すメモリを侵食し、移動先が不正になっているのではないか
  • 移動回数自体はカウントされていないが、移動元と移動先の座標を保存しているメモリの上限が256回目の移動で不正な値に書き換わっているのではないか
  • 移動の履歴をスタックにプッシュしており、256回目の移動でスタック領域が溢れ、意図しないメモリの値が書き換わったのではないか

こうして見ると、最初の疑問は「本当に移動回数がゲーム内で保持されているのか」であるように思います。

結論から言うと、移動回数は確かに保持されています。具体的には、RAM$7E141A(1byte長)が移動回数を保持しています。このRAMは、マップからコースに入る際には$00となっており、コース内で土管やドアを用いてエリア移動を行うと1インクリメントされます。SMW Centralによると、このRAMは以下のような機能を持ちます。

Counter that increments every time a new level is entered (with a door or pipe) - this enables you to distinguish the 'mother'-level from sublevels, as this always is zero at the start of a level. (SMW Central, n.d.)

よって、まず第一の疑問である「移動回数がゲーム内で保持されているのか」という問いについては、明確な結論が得られました。

1byte長のメモリにおけるオーバーフロー

次に、ご存じの方も多いかもしれませんが、1byte長のメモリにおいてオーバーフローが発生したときの挙動について解説します。すなわち、$FF(255)が保持されているメモリに対して、加算を行った場合の挙動です。

LDA #$FF  ; A = $FF
STA $00   ; $7E0000 = $FF
INC $00   ; $7E0000 = $FF + $01 = $??

スーパーファミコンにおいては、1byteのメモリ、すなわち8bitのメモリは、$00(0)から$FF(255)までの256通りの数値を保持することができます。では、$FF(255)が保持されているRAMアドレスに対してINC命令(1インクリメント)を実行すると、そのメモリが保持する値はどのようになるのでしょうか?

結論、値は$00になります。これを「オーバーフロー」といいます[1](メモリ長が2byteである「ことにする」とこの限りではありませんが、今回のケースでは当てはまらないので、以下に参考として記載しておきます)。

LDA #$FF  ; A = $FF
STA $00   ; $7E0000 = $FF
INC $00   ; $7E0000 = $FF + $01 = $00
LDA $00   ; A = $00
16bitモードにおける挙動(参考)

スーパーファミコンに使用されているW65C816プロセッサには「16bitモード」(厳密には、「メモリモード」と「インデックスモード」に分かれています)という、おそらく当時としては画期的な機能が実装されています。

簡単に説明すると、これは、このモードを有効としたとき、1つのアドレスに対して、その次のアドレスと指定されたアドレスを、16bit(2byte)長を持つひとつのメモリとして取り扱う、という機能です。16bitモードにおいては、保持できる値は$0000から$FFFF(65535)までの65536通りとなります。よって、16bitモードにおいて上記のような処理を実行した場合、以下のような挙動になります。

REP #$30    ; enable 16-bit mode
LDA #$00FF  ; A = $00FF
STA $00     ; $7E0000 = $FF, $7E0001 = $00
INC $00     ; $7E0000 = $00, $7E0001 = $01
LDA $00     ; A = $0100
SEP #$30    ; disable 16-bit mode

なお、W65C816プロセッサでは、リトルエンディアン方式が採用されているので、$0100が.db $00, $01のように表現されます

先述した通り、RAM$7E141Aは1byte長のメモリであり、1byteとしてアクセスされるため、256回目のエリア移動においてRAM$7E141Aの値は$00となります。

このことより、動画のコメント欄における、「メモリが保存できる値の上限を超えてしまった」という考察はその通りですが、そのことによって、他のメモリ領域を侵食するということはない、ということが分かりました。

結局、なぜバグるのか?

では、移動回数を保存するRAM$7E141Aがオーバーフローしたとしても他のメモリには影響を及ぼさないのに、なぜ、256回目のエリア移動で不正なコースに移動してしまうのでしょうか?これを理解するには、マリオワールドのROMに書かれているプログラムを理解する必要があります。

マリオワールドがコースのデータを読み込む処理は、ROM$05D796から857byteに渡って記述されているサブルーチンにて実行されています。

;CODE_05D796
PHB                       
PHK                       
PLB                       
SEP #$30            
STZ $13CF 
; ...... continue until $05DAEE

このサブルーチンでは、複数の論理演算や分岐処理、算術演算などを組み合わせて、マリオが正しいコースに移動できるように処理を行っています。

このサブルーチン内、ROM$05D7ABに次のような処理が記載されています。

;CODE_05D7AB
LDA $141A
BNE $03          
JMP $D83E
詳細な各命令および挙動の解説

LDA $141AはアキュムレータにRAM$7E141Aの値を読み込みます[2]。ここで、アキュムレータは8bitモードなので、アキュムレータに読み込まれる値はマリオのコース内移動回数そのものになります。BNE $03は、プロセッサフラグのうち、ゼロフラグが立っていないときにプログラムカウンタを$03byte進めます[3]。一方、ゼロフラグが立っているときには、プログラムカウンタは次のオペコードに進み、JMP $D83Eを実行します。なお、ゼロフラグが立っておらず、BNEによる分岐が行われた場合には、プログラムカウンタはJMP $D83Eの次の命令に進みます。一方、ゼロフラグが立っていてJMPが実行された場合には、JMP $D83E以下の命令から$05D83Dまでの処理は実行されません。なお、ゼロフラグは、レジスタに$00が読み込まれるか演算結果が$00になるとフラグが立ち、$00以外が読み込まれるとフラグが折れるという特徴があります。よって、RAM$7E141Aが$00のとき、すなわちマップからコースに入ったとき、この分岐処理は実施されずJMP $D83Eが実行されます。一方、$00でない値のとき、すなわちコース内の移動であるとき、この分岐処理が実行されて、JMP $D83Eの下にある命令から実行していくことになります。

65C816(もしくはアセンブリ言語)に慣れていない方は少し戸惑われたかもしれません。下に高級言語(C#)でこの処理を記載したものを掲載しておきます(実際には、分岐先の関数は元関数のローカル関数のような形になっていますが、簡略化のために分けて書いています)。

    int levelEnteringCounter = 0; //RAM$7E141Aに相当
    int a = 0; //アキュムレータに相当
    
    // この間にもさまざまな処理が行われていて......
    
    // ROM$05D796に相当
    void LevelLoading()
    {
        // $05D7AAまでの処理は省略
        a = levelEnteringCounter; // 一度アキュムレータを経由するのが65C816の特徴
        if (a != 0)
        {
            DataLoadingForInLevel();
        } else {
            DataLoadingForEntering();
        }
    }

    void DataLoadingForInLevel()
    {
        // 処理内容については後述
    }

    void DataLoadingForEntering()
    {
        // 処理内容については後述
    }
    

......まだ少しよく分かりませんね。でも実は、ここで紹介した、ROM$05D7ABから始まる8バイト3命令によって構成されるこの分岐処理こそが、今回紹介したバグを発生させる根本的な原因になります。

ではここで、フローチャートを用いて、問題の処理を分かりやすく理解してみましょう。

上のフローチャートの通り、この分岐処理では、RAM$7E141Aが0でない場合に「コース内でエリアを移動するときの処理」を実施し、RAM$7E141Aが0のときに「マップからコースに入るときの処理」を実施する分岐が実装されています。つまり、このサブルーチンは、コース内で土管やドアを用いてエリア移動を行う際にも、マップ画面(コース選択画面, overworld)からコースに入る際にも共通して呼び出されるサブルーチンであり、RAM$7E141Aの値が0であるかによってそれぞれに対応した処理を実行しているのです。

「移動回数は本当に保存されているのか」の項で説明した通り、RAM$7E141Aは、マップからコースに入るときには0が保存されているので、この分岐処理によって、いま実行すべき処理はどちらか、を判別することが可能です。

しかし、前項で見た通り、1byteしか持っていないRAM$7E141Aは、256回目のエリア移動の際に、その値が$00に戻ってしまいます。よって、256回目のエリア移動において、コース内の移動であるにも関わらず、「マップからコースに入った」と認識され、本来実行されるべき処理とは異なるデータ読み込み処理が実行されるのです。なお、256回目の移動先については、関連しているメモリや論理演算等が多く、まだ算出フローの特定には至っていませんが、計算過程をすべて辿ることができれば、固定化が可能かもしれません。今後の研究課題とします。

おわりに

ここまで、スーパーマリオワールドで256回エリア移動をするとバグが発生する理由について解説してきました。結果として、このバグは、「移動回数を保持するRAM$7E141Aがオーバーフローして0になった結果、コース内のエリア移動であるはずなのに、マップからコースに入る際のデータ読み込み処理が実行されるために発生するバグ」であると結論づけることができました。

今回の研究を通して、こうしたRTAでの利活用性が少ないバグに対しては、(筆者が動画を観るまでこのバグを知らなかったように)あまり研究者によるスポットが当てられず、その原因についても不明なままとなっているものが多いのではないか、と感じました。筆者はマリオワールド研究者として、RTAの新ルートとなるようなバグ・任意コード実行はもちろん、こうした細かいバグについても研究を行なっていこうと考えています。スーパーマリオワールドにおける未解明のバグの情報等あれば、筆者のXのDMまで、お気軽に情報をお寄せいただければ幸いです。

最後になりますが、筆者に本バグを研究するきっかけをくださった、伝説のスターブロブ2さまに感謝を申し上げて、本記事の締めとしたいと思います。

参考文献

https://youtube.com/shorts/mlNKIVZiY1c?si=KPck3Sx76HjT_cQF
https://www.smwcentral.net/?p=memorymap&game=smw
https://www.smwcentral.net/?p=section&a=details&id=35277
https://donkeyhacks.zouri.jp/databank/65C816/65c816.html
http://www.6502.org/tutorials/65c816opcodes.html

脚注
  1. W65C816においては、「オーバーフロー」の用語は、符号付き算術オーバーフロー(signed arithmetic overflow)を表しますが、ここでは一般用語として、メモリが保持できる数値の上限を超えることとして用いています。 ↩︎

  2. 実際には、DBレジスタが$05であるため、$05141Aを読み込みますが、この領域はRAMのバンク$7Eのミラーなので、$7E141Aの値(と同じ値)が読み込まれます。 ↩︎

  3. 実際には、オペランドの最下位バイト、すなわち今回のケースでは$03が書かれているアドレスROM$05D7AFの次のアドレスであるROM$05D7B0から$03バイトを進めます。 ↩︎

Discussion