📟

追加研究:『マリオワールドで256回エリア移動をするとバグる理由』

に公開

はじめに

こんにちは。スーパーマリオワールド研究者の倫葉(ともは)と申します。

先日、YouTubeで見つけたショート動画をきっかけとして、『マリオワールドで256回エリア移動をするとバグる理由』という技術記事を投稿し、初心者の方でも分かりやすいような解説動画をYouTubeへ投稿しました。

https://youtu.be/pW7n1WjSzEw?si=rewPC-hcwu3ERfec

ありがたいことに、この動画のコメント欄では、多くの視聴者の方に、このバグについての疑問や考察などをコメントしていただきました。この記事では、コメントでいただいた主に3つの疑問や考察について、筆者が行った追加研究の結果を解説します。

なお、内容についてはYouTubeのコメント欄で返信したものと重複しますが、この記事では、より詳細な技術面やプログラムの解説を行なっていきます。

なぜ移動「回数」を数えているのか

前掲の記事および動画で、移動回数を保持するメモリRAM$7E141Aは、その値が$00であるとき「マップからコースに入ったこと」を表し、$01以上の値であるとき「コース内のエリア移動であること」を表すための1バイト長メモリであると解説しました。しかし、他の用途で用いられていることはないのか、という疑問は残ります。

そこで、スーパーマリオワールドのプログラム内で、絶対アドレスモード$addrによって$7E141Aにアクセスしている命令をすべて確認しました(VSCodeの検索で「$141A」と検索しました)。

結果、絶対アドレスモードで$7E141Aにアクセスしている命令は11個存在しました。

うち、ROM$00D273およびROM$05DBC5からともに3バイトはINC $141Aであり、これは$7E141A自体を1インクリメントさせる命令になります。$00D273はコース内エリア移動の際に$7E141Aを1インクリメントさせる命令で、$05DBC5はヨッシーが"wing"を取得した際に$7E141Aを1インクリメントさせる命令になります。

一方、そのほかの9つの命令の内訳は以下のとおりです。

$7E141Aにアクセスしているオペコード 回数 当該オペコードに続くオペコード
ORA ($0D) 2 ORA
LDA ($AD) 7 ORA BNE BEQ

ORA $addr命令は、アキュムレータの値と絶対アドレスに保持されている値の各ビットの論理和を取り、アキュムレータにその結果を返す命令で、条件分岐の際に、複数の条件を用いる場合などに使用します(分岐条件を定めるオペコードにより、if (state1 && state2)if (state1 || state2)のどちらも処理できる)。RAM$7E141Aにアクセスしている2つのORA命令は、その後いくつかのORA命令により条件を設定した上で、BNE命令で分岐を行なっています。

BNE命令は、プロセッサフラグのうちゼロフラグが立っていないときに分岐を実行する命令で、ゼロフラグは演算結果が$00となるか、レジスタに$00が読み込まれた際に立ちます。

ORA $141Aを行っている2つのサブルーチンは、BGMのアップロードに関連するサブルーチンとコースデータの読み込みにかかるサブルーチンであり、最終的にはBNEで分岐していることから、RAM$7E141Aが$00かそれ以外かを判断するために用いられていると考えられます。

一方、アキュムレータ(レジスタ)に$7E141Aの値を読み込む7箇所では、直後に$7E141Aが$00のとき分岐するBEQ命令、もしくは$00以外のとき分岐するBNE命令が実行されるか、ORA命令で他の条件を付してBNE命令が実行されていました。

$7E141Aを参照している箇所では数値を比較するCMP命令等が用いられておらず、ゼロフラグによる分岐のみが実行されていることから、$7E141Aは、マップからコースに入ったか、コース内のエリア移動であるかを識別するためのフラグであると考えられます。

ただし、STZ $141A等の命令が見つからなかったことから、実際には、$7E141Aは間接アドレッシングモードやインデックスモードでもアクセスされている可能性が高く(=今回の検索で見つかった11の命令だけがRAM$7E141Aにアクセスしているわけではない)、他の用途で用いられている可能性も否定できません。この点については、『どうすればよかったのか』の章でも触れます。

なぜお城に入る演出が入るのか

この疑問を理解するためには、65C816アセンブリの処理について少し詳しい導入が必要であるため、いくつかの項に分けて解説を行います。複雑な処理であることから、解説も難解になってしまう点をご了承ください。

DEX-BPLループの導入

「お城に入る演出」を挿入するかどうかは、ROM$05DA24から$05DA36の処理によって決定されます。

CODE_05DA24:        LDX #$04 
CODE_05DA26:        LDY #$04 
CODE_05DA28:        LDA [$65],Y
CODE_05DA2A:        AND #$0F  
CODE_05DA2C:        CMP $05D760,X   
CODE_05DA30:        BEQ $06           ; Branch to $05DA38
CODE_05DA32:        DEX 
CODE_05DA33:        BPL $F7           ; Branch to $05DA2C 
CODE_05DA35:        JMP $DAD7 

この処理では、以下のような処理が実行されています。

  1. XレジスタとYレジスタに$04をロードする
  2. ダイレクトインダイレクトロングインデックスYモードにより、RAM$7E0065から3バイトに保持されている値をリトルエンディアン[1]のロングアドレスとし、そのロングアドレスの4バイト先に保持されている値をアキュムレータにロードする
  3. 2でロードしたアキュムレータの値の各ビットと$0F(%00001111)との論理積を取り、その結果をアキュムレータに返す
  4. 絶対ロングアドレスインデックスXモードにより、アドレス$05D760にXレジスタの値をインデックスしたアドレスに保持されている値とアキュムレータの値を比較する
  5. 4の比較が真のとき、$05DA38に分岐する(6および7は実行されない)
  6. 4の比較が偽のとき、Xレジスタの値を1デクリメントし、ネガティブフラグが立っていない場合(デクリメントの結果が符号付き16進数の負数でない場合=ここでは、$FFでない場合)、$05DA2Cへ分岐する(4に分岐する)
  7. 6の分岐が行われなかった場合、$05DAD7へジャンプする

少し複雑に思えますが、4から6のDEX-BPLループは、高級言語ではお馴染みのfor/whileループと同じものになります。ここで、1から7の処理をC#で記述したものを見てみましょう(分かりやすくするため、C#として厳密なコードではありません)。

void Hoge()
{
    x = 0x04;
    y = 0x04;
    a = ram[DirectIndirectLongIndexedY(0x65) + y];

    a = a & 0x0f;

    while (x >= 0x00)
    {
        if (ram[0x05d760 + x] == a)
        {
            // BRA $06
            goto ROM05DA38;
        } 
        else 
        {
            x --;
        }
    }
    
    // JMP $DAD7 
    goto ROM05DAD7;
}

int DirectIndirectLongIndexedY(int directPage)
{
    // 後述
}

筆者が調べた限り、お城に入る演出が挿入される場合、このループ処理内の条件分岐が成立し、JMP $DAD7が実行されます。条件分岐の比較は以下の2つが同値かを判断しています。

  • $7E0065から3バイトに保持されている値をリトルエンディアンのロングアドレスとし、そのロングアドレスの4バイト先に保持されている値と$0Fとの論理積を取った値
  • テーブル:ROM$05D760から$05D764に記述されている各値($05,$01,$02,$06,$08

ここでは、前者が理解しづらいと思われるため、以下に解説します。

ダイレクトインダイレクトロングインデックスYモードの導入

アドレッシングモード「ダイレクトインダイレクトロングインデックスYモード」(OPC [$dp], Y)は、第1オペランドで指定されたダイレクトページアドレスから3バイトをロングアドレスとして読み取り、そのアドレスにYレジスタの値をインデックスしたアドレスを実効アドレスとします。

例えば、Yレジスタに$03が保持されているとして、以下の条件で、LDA [$00], Yを実行した場合を考えてみましょう。

アドレス 数値
Y $03
$7E0000 $D0
$7E0001 $84
$7E0002 $00
...... ......
$0084D0 $7D
$0084D1 $83
$0084D2 $7F
$0084D3 $75

このとき、まず、[$00]つまり、RAM$7E0000から3バイトに保持されている値をアドレスとして読むことから始めます。W65C816はリトルエンディアン方式を採用しているため、下位バイトから順に読んでいくので、[$00]が表しているアドレスは$0084D0になります。次に、このアドレスにYレジスタの値である$03をインデックスすると、実効アドレスは$0084D3となります。よって、LDA [$00], Yでアキュムレータにロードされる値は$0084D3が保持している$75となります。

お城の演出が挿入される理由

ダイレクトインダイレクトロングインデックスYモードを理解いただいたところで、なぜ$05DA24がお城の演出に関わっているかを解説します。

前述の通り、このループ条件分岐では、以下の2つの値を比較します。

  • $7E0065から3バイトに保持されている値をリトルエンディアンのロングアドレスとし、そのロングアドレスの4バイト先に保持されている値と$0Fとの論理積を取った値 すなわち、Yレジスタが$04のとき、アドレス[$65], Yが保持している値と$0Fとの論理積
  • テーブル:ROM$05D760から$05D764に記述されている各値($05,$01,$02,$06,$08

ここで、これら2つの値が何を示しているのかを説明します。

前者について、SMW Centralによれば、RAM$7E0065から3バイトには、コースおよびマップのLayer 1データの24-bitポインタが保持されています。ここでは、「コースのデータが書かれている領域の最初のアドレスが保持されている」と考えてください。おそらく、コースのLayer 1データが始まるアドレスから4バイト先は、当該コースの演出に関わるフラグが書き込まれており、$0Fとの論理積により、お城の演出が挿入されるべきかを判断できるのだと考えられます[2]

後者について、SMW Centralによれば、このテーブルは"The tileset that each no-Yoshi intro intro is used with."を示すテーブルであり、前者の計算結果がこのテーブルのいずれかの値と合致するとき、条件分岐が成立してBEQ $06が実行され、プログラムカウンタがROM$05DA38に飛び、以下の命令が実行されます。一方、条件分岐が成立しなかった場合、すなわち前者の計算結果がテーブルの値と合致しなかった場合、JMP $DAD7が実行され、以下の命令は実行されません。

CODE_05DA60:        LDA $13CF               
CODE_05DA63:        BNE $6B   

SMW Centralによれば、RAM$7E13CFはお城・お化け屋敷に入る演出を再生するかどうかを判断するフラグで、このメモリが保持する値が$00のとき演出を再生し、non-zeroの場合には演出は再生されません。

ヨースター島コース2で土管に256回入った際には先述の条件分岐が成立し、かつ、$7E13CFに$00が入っていることから、お城に入る演出が再生されるようです[3]

なお、Layer 1データの24-bitポインタはコースによって当然異なるため、コースによっては、256回目のエリア移動であっても条件分岐が成立せず(計算結果がテーブルの値と一致せず)、お城の演出が挿入されない場合もあると考えられます。

応用:没要素"No Yoshi Intro"が再生される場合

没要素であるNo Yoshi Introの再生は、RAM$7E141Fで管理されています。SMW Centralによれば、この値が$00のとき、タイルセットに応じてNo Yoshi Introが再生されます。具体的にこのアドレスを参照しているのはROM$05DA42であり、以下の通り、$7E141Fがnon-zeroであるかで分岐を行っています(non-zeroの場合、タイルセットに関わらずNo Yoshi Introは再生されない)。

CODE_05DA42:        LDA $141F               
CODE_05DA45:        BNE $EE

この条件分岐が実行されるのは、DEX-BPLループにおける条件分岐が成立した場合であることから、256回目のコース内エリア移動でDEX-BPLループの条件分岐が成立し、かつ、$7E141Fが$00であり、かつ、コースのタイルセットが特定の条件を満たしたものであるときにNo Yoshi Introが再生されるものと思われます。

どうすればよかったのか

では結局、「256回エリア移動をするとバグる」というバグを防ぐために、任天堂のプログラマはどのような実装をすればよかったのでしょうか?

実は、前回の記事を投稿する際に筆者も少し考え、Game Mode:RAM$7E0100を使用する方法や、サブルーチンを分ける方法などを考えたのですが、Game Modeでは正確に判別できなかったり、サブルーチンを分けるのは煩雑であるといった理由で、前回の記事にはこの観点について触れませんでした。

しかし、動画をご覧いただいた視聴者の方から画期的な(というよりもむしろ、ごく単純で、そのため逆に筆者が思いつかなかった)アイデアをいただきました。

それは、「エリア内コース移動を行うたびにRAM$7E141Aに$01を書き込む」というものでした。すなわち、エリア内コース移動のたびにINC命令でメモリの値を1インクリメントさせるのではなく、LDA-STAにより$01を上書きし続けるというアイデアです。

このアイデアを確かめるため、とある技術を用いて、このアイデアが実現可能か(このことによって他の処理に副作用を起こさないか)を試してみました。

$7E141Aをインクリメントさせる命令は、ROM$00D273およびROM$05DBC5に記述されています。記述されている命令はともに以下の通りです。

INC $141A

$00D273は、コース内エリア移動の際に$7E141Aを1インクリメントさせる命令で、$05DBC5はヨッシーが"wing"を取得した際に$7E141Aを1インクリメントさせる命令です。

今回の研究では、これら2つのINC $141A命令を、$7E141Aに$01をストアする命令を記述したサブルーチンへのジャンプ命令へ書き換えました。パッチ内容は以下の通りです(Asarの使用を想定)。なお、サブルーチンを記述した領域は、$FFで埋められている不使用領域です。

lorom

org $00d273
            jsr $f9f5

org $00f9f5
            lda #$01
            sta $141a
            rts

org $058e19
            lda #$01
            sta $141a
            rts

org $05dbc5
            jsr $8e19

このパッチを当てたROMで、スーパーマリオワールドRTAのカテゴリーである11 Exit(スターロード経由でクッパを撃破するルート)を走ってみました。

結果、何の問題もなく完走できました

筆者が中〜長距離のカテゴリーを走れないという問題から、検証できたコースが少なかったため、他のコースで問題が発生する可能性はありますが、おそらく、いただいたアイデアが最も単純に問題を解決できるのではないかと思います。

任天堂がこのような実装を行わなかった理由について、容量削減やコードの可読性などさまざまな理由は考えられますが、いちばんは「256回もコース内でエリア移動されることを想定していなかった」ことに尽きると思います。だって、そんなことするプレイヤーがいるなんて考えられないですから。筆者もそんなことしようと思ったことなかったですし……。

ただし、『なぜ移動「回数」を数えているのか』の章でも触れたとおり、$7E141Aが間接アドレッシングモード等で、ゼロ分岐ではない数値比較として参照されている可能性は否定できないため、この解決策が必ずあらゆる場面で機能するかについては、より詳しい検証が必要になると考えられます。

番外編:倫葉のチャンネル登録者数はオーバーフローするか


しませんでした。登録いただいた皆さん、ありがとうございます。

おわりに

前回の記事、今回投稿した動画ともに、筆者が想定していたよりも大きな反響いただき、嬉しく感じるとともに、検証の過程でスーパーマリオワールド研究の面白さを改めて実感しました。また、コメント欄でいただいた質問に答えていく中で、よりスーパーマリオワールドのプログラムについて深く触れることができ、知見が非常に深まりました。この場をお借りして、記事や動画をご覧いただいた皆様に感謝申し上げます。

実は、筆者がROMの逆アセンブルからゲームの挙動について詳細な研究を行ったのは、2022年に発表した『チョコレー島砦におけるBowser Statue Fireballの生成に関するサブルーチンの解析』(倫葉, 2022)以来でした。いろいろ思い出しながらの解析、検証でしたが、とても楽しく研究を進めることができました。今後も、スーパーマリオワールド、そしてSNES・65C816アセンブリの面白さを皆様にお伝えできればと思います。

参考文献

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. オペランドを下位バイトから表記していく方式。 ↩︎

  2. つまり、この処理で実行していることは、24-bitポインタの4バイト先のアドレスから値をロードし、$0Fとの論理積を取ることです。 ↩︎

  3. 土管に256回未満入った状態では、先ほどの条件分岐はそもそも実行されないため、$7E13CFに$00が入っていても問題になることはありません。あくまでこれらの処理は、「マップからコースに入った際に実行される処理」であることに注意してください。なぜコース内移動であるにも関わらずこの処理が実行されるかは、以前投稿した記事『マリオワールドで256回エリア移動をするとバグる理由』を参照してください。 ↩︎

Discussion