ピロ彦の何か置き場

ドラクエ4 TAS の サブフレームリセットと任意コード実行の補足

※注意:TASの操作は大変危険なものですので決して真似しないでください。


 今回のTASは、TAS制作統合エミュレータであるBizhawkの開発版に実装されたSubNESHawkによってファミコンでもサブフレームリセットサブフレームインプットが可能になったのを知って急遽制作しました。
 ざっくりとチャートを説明すると、0人PTを作って並び替えをした時にバグってSRAMをプログラムとして呼び出すのでそれを利用して任意コード実行しよう、ということです。

 この画像はBizhawkでコアにSubNESHawkにしている時のTAStudioのタイムラインです。

 赤いラインは通常はラグフレームとして扱われますが、SubNESHawkではVBlankが発生した時に挿入され、プログラムがコントローラの入力処理を検知するたびに緑色のサブフレームインプットが発生します。
 ドラクエ4ではカーソル移動処理時に1フレームに2回×2コントローラの入力受付があり、サブ1で決定・キャンセルの判定をしサブ3でカーソル移動の判定をしています。
 そのおかげで通常のTASと違い、カーソルが毎フレーム連続移動出来るので名前入力時などのカーソル移動がとても速くなってます。
 左のカラムにReset Cycleと書かれているのはリセット入力時に待機するPPU Cycleを数値で入力するもので、『けこけこ』[13 14 13 14]冒険の書3に書き込まれる瞬間のトレースログを見ると実際にPPU-Cy:30400を越えたところでリセット処理が発生していることが分かります。


 名前を入力して「おわり」を選択した瞬間に冒険の書に名前が書き込まれますが、メッセージ速度を決定するまではチェックサムが0000のままなので、その前にリセットをすると冒険の書が消えるメッセージを簡単に見ることが出来ることができます。

 動画3秒の地点でのエラー処理中にサブフレームリセットをすることで冒険の書3の名前の手前までを4Bで埋め尽くしています。
 これをしない場合は0人PTでの並び替え時に$68F1が実行された時に 00:BRK 命令が発生して目的地にたどり着けないので無害な命令である 4B:ALR #immで回避しています。
 この命令は未定義命令ですが A = A AND immediate, A = shift right A という処理が実行されるので通過し終わった頃にはCPUのAレジスタ00になってます。

 『けこけこ』[13 14 13 14]という名前は 13:ASO ($14),Y というプラグラムを2回実行するためにつけてます。

  00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
00
10
20
30      
40
50
60
70     Ex

 ASOという未定義命令は memory = shift left memory, A = A OR memory という処理が実行されるので、単に左シフトとして利用します。($14),Yの部分は$14$15の2バイト+Yの間接アドレス先のメモリを書き換えるというものです。
 未定義命令はエミュレータによっては無視されたりエラーを起こしたりしますが、現在のTAS環境においては未定義命令も実機と同等の動作をするようなので遠慮なく使います。
 $14はコントローラ1、$15はコントローラ2の入力が反映するメモリなので、好きなアドレスのメモリ値を2回左シフトすることになります。純粋な左シフトは間接アドレスが使えないのでこちらを採用しています。『け』は『こ』に近いので便利です。
 ちなみに一番最初に『けこけこ』の処理をする理由は冒険の書が多いほうが処理が多くなるからで、冒険の書が埋まったときのほうがウィンドウの処理自体は速いですが総合的には遅くなります。

 続いて冒険の書1に『にへ゜ 』[20 27 6A 00]という名前をつけてスタートしています。この名前は 20:JSR $6A27という命令で、実行されると冒険の書3の所持金のアドレスにサブルーチンとしてジャンプすると意味になります。
 純粋なジャンプ命令である4Cや相対ジャンプでループするための負数(0x80以上)は名前から得られませんが、エニックスが『゜』を6Aにしてくれたので冒険の書3の中でループを作る事が可能になっています。
 制作直後の冒険の書にはPTの加入情報は0なのですが、第一章が始まる時にライアンの加入処理が発生するので、0人PTを作るためにまず教会でセーブした状態を作ります。

 動画53秒地点で冒険の書1を2にコピーした直後に冒険の書2を消して、再び冒険の書1を2にコピーをしていますが、この作業でデータ2の所持金が[32 00 00]=50Gだったものが[4B 4B 4B]=4,934,475Gになります。



 $65DF・$65E0冒険の書2のCRCっぽいチェックサムとなっていますが、16bitのCRCを調整するのは簡単ではありません……

 と思いきや、最終的な計算結果の比較処理にあるべき1行が抜けてる為に8bit分しか判定に貢献していません(;´Д`)

 こんな感じのFCEUX用のLuaスクリプトを書いて画面に表示して、HEXエディタで4Bを書き込んでいって偶然にも$65E0CRCの計算結果が一致するものを手動で探してました。BizhawkよりもFCUEXの方がHEXエディタが使いやすかったのでチャート構築や解析はこっちでしてました。
 このように4Bで埋める範囲を調整することでCRC調整出来ますが、$66A8から$66AFまでのライアンの道具欄も4Bで埋めてしまうと天空の兜(4B)だらけになって売り買いが出来なくなります(´・ω・`)
 $673C00にしてるのは、そこも4Bで埋めた場合は道具欄が埋まるまでにCRCが一致しなかったからです。冒険の書のメッセージ速度はデータの一番後ろにあるので、+1するごとに+0x10*nをXORしたCRCになるので5bit分だけ一致していれば調整可能でしたが、今回はなんとかその手間を掛けずにCRCを一致させることが出来ました。

 さて、名前のアドレスと近い位置にある所持金を増やすことに成功したので動画1分過ぎから冒険の書2で金額調整に入ります。
 ここで欲しい値は[D3 14]で、これはD3:DCM ($14),Y という命令になり$14-$15のコントローラ情報を利用して特定のアドレスの値を1つ減らすことが可能になります。
 DCMという命令も未定義命令で memory = memory - 1, compare A and memory という処理がされるので、比較部分以外はほぼDEC命令と同じですがDECは間接アドレスを扱うものがないのでこちらを使っています。
 しかし売り買いだけでその金額にしようとすると13944Gも買い物をしないといけないので、買っては捨てて買っては捨ててで時間がエラい掛かります。 そこで先程用意した冒険の書3の『けこけこ』[13 14 13 14]を用いた任意コード実行で金額を変更してしまおうという魂胆です。


 先に所持金を[D3 45 4B]に調整します、そのために必要な購入額は1400Gと分かりやすいので調整も楽にできます。 $67380x45を2回左シフトすると、0x45*4=0x114となり、8bit分の0x14が残ります。
 このコードを実行するために0人PTを作ります。上図の$674Aにある[86 00 00 00]がライアン・無人無人無人というパーティー構成を意味しているのでこのライアン00にしてしまえば良いのですが、4Bをつっこんでも存在しないキャラ扱いになって0人PTになるので冒険の書1を削除して$645Aライアン[86]まで4Bで埋めて、冒険の書2を1へコピーしてCRCを再度一致させます。


 このとき$645Aまでではなく、PTの2人目以降も4Bで埋めた場合は神父に呼ばれる名前が改行込みのちょっと長い空白になり、人数が多いほどロードも長くなるので今回のTASでは良い感じに調整できてますが駄目な場合は$63A5のライアンの生存フラグまで4Bで埋まってもCRCが一致しない可能性がありました。 なお名前も上書きされて『ソソソソ』[4B 4B 4B 4B]になってますが問題ありません。

 冒険の書1に0人PTを作れたので、早速1回目の任意コードを実行していきます。 0人だとバグが発生するのはRPGには良くあることで、人数分の処理を行う際に0からスタートすると判定前に-1して255になってしまうのが主な原因だと思われます。
 動画時間1分39秒で並び替えを実行した後の入力判定で1Pは上下スタート、2Pは下左セレクトBAを押すことで$14が0x6738となり、ソソソソ†が32人分ほど表示されたタイミングでバグが発生して冒険の書3の$68F1が呼び出され、4B4Bゾーンを通り抜けて$6A2D『けこけこ』[13 14 13 14]が実行されて$6738が晴れて4514になるわけです。


 ちなみ余談ですが上図でいうところの$6775$6777はそれぞれ船座標XYと気球座標XYで、教会でロードをすると街に対応した位置に配置されてて街を出る時にフラグチェックをして0000に戻されたりします。 船・気球フラグは冒険の書2でいうところの$686Eでこの画像よりも大分下の方にあり、船がある時は+01・気球がある時は+02という感じになってます。
 つまり4Bだと船も気球も手に入った状態になるということなので、一章が始まって教会する前にリセットした冒険の書を空っぽのデータにコピーし終わる前にリセットを押すと1/256の確率でCRCが一致し、上書き位置が悪くなければロード後に一章が全滅BGMと变化状態で始まり、教会で再開かキメラの翼を使うと気球と船が外に配置されるようになります。
 失敗例としてはリセットが早すぎて名前の2バイト前にある章番号が4Bになったせいで、4B章が始まってしまうケースです。 このデータでは主人公不在のバグった5章スタートで、並び替えバグをしても$62F1が実行されないので多分役に立ちません。
 ただし、成功例でもデータの終端にあるメッセージ速度が4Bになってしまい、48~4Fの範囲外に戻すことが出来なくなって戦闘が凄く遅くなります(´・ω・`)
 名前によって成功率が変わると思いますが、コピー決定の2~3フレーム後くらいに適当にリセットを押していれば実機でも現実的な試行回数で一章から気球に乗れるようになると思います。
※追記 戦闘中にセレクトを押して速度を3に変更するとメッセージが速くなるそうです!!

 話を戻すと、動画時間1分40秒で冒険の書2の所持金が[D3 45 4B]から[D3 14 4B]になったことで冒険の書2のCRCが一致しなくなり、データ消去処理が発生するので前述と同様に消去中のサブフレームリセット冒険の書1から2へ上書き中のサブフレームリセット冒険の書2を復活させます。

 この先は冒険の書1しか起動することはないので、冒険の書2は所持金と名前さえ無事ならどの範囲を使って調整しても大丈夫です。 じゃあ何のために冒険の書2を復活させたかというと、冒険の書3にコピーするためです。

 冒険の書3はコピーしたあとに00を埋めるために所持金手前まで4Bで埋めます。 消えたデータ扱いでいいのでCRCは気にしません。

 これによって欲しいプログラムの第一形態が完成しました。以下プログラムの中身↓

  $68F1: 4B 4B     ALR #$4B    Aレジスタと#$4Bの論理積を取った後に右シフト
  $68F3: 4B 4B     ALR #$4B  
  ---------------------------
  $6A27: D3 14     DCM ($14),Y  $14の16bitにYレジスタを足した間接アドレス先を1減らす
  $6A29: 4B 4B     ALR #$4B  
  $6A2B: 4B 00     ALR #$00    Aレジスタは0になる
  $6A2D: 20 27 6A  JSR $6A27   $6A27へサブルーチンとしてジャンプする

 $6A27から$6A2Dの間で無限ループして好きなアドレスを好きな値に書き換えることが可能になりましたが、無限ループ中に$14を変更することは出来ないので狙った値になるタイミングでリセットをかける必要があります。動画1:52では$6A260xA8に、1:59で$6A220xECに、2:07で$6A230xC8に、最後に2:13で$6A210x20にしています。
 A8:TAYはAレジスタをYレジスタにコピーする1バイトの命令で、3バイト命令を通った後にプログラム位置がずれるのを直すのとYレジスタを0にするために設置しています。
 [20 EC C8]JSR $C8EC で、コントローラ情報を取得して$14と$15を更新するサブルーチンになってます。
 これで晴れてループ中にコントローラ情報を更新できるようになったかと思いきや、まだループ外なので最後にちょっとした工夫をします。

 現在のループは$6A27へジャンプするものなので、$6A2Eの27を減らして$6A21へジャンプするようにするようにすれば良いのですが、1つずつ減らす間にループ位置が一つずつずれるので正しくループが出来なくなる可能性がありますが、そこはちゃんと考えての配置にしてます。 以下は一連の流れ。

  $6A21: 20 EC C8  JSR $C8EC   コントローラ情報を取得し$14-$15を更新するサブルーチン
  $6A24: 4B 4B     ALR #$4B     $C8ECの後、0x10になっているAレジスタが0x00になる
  $6A26: A8      TAY        YレジスタにAレジスタがコピーされる
  $6A27: D3 14     DCM ($14),Y   ($14)+Y=$6A2Eを1減らす
  $6A29: 4B 4B     ALR #$4B  
  $6A2B: 4B 00     ALR #$00  
  $6A2D: 20 26 6A  JSR $6A26    $6A26へジャンプ
  -----
  $6A26: A8      TAY        YレジスタにAレジスタがコピーされる
  $6A27: D3 14     DCM ($14),Y   ($14)+Y=$6A2Eを1減らす
  $6A29: 4B 4B     ALR #$4B  
  $6A2B: 4B 00     ALR #$00  
  $6A2D: 20 25 6A  JSR $6A25    $6A25へジャンプ
  -----
  $6A25: 4B A8    ALR #$A8
  $6A27: D3 14     DCM ($14),Y   ($14)+Y=$6A2Eを1減らす
  $6A29: 4B 4B     ALR #$4B  
  $6A2B: 4B 00     ALR #$00  
  $6A2D: 20 24 6A  JSR $6A24    $6A24へジャンプ
  -----
  $6A24: 4B 4B    ALR #$4B
  $6A26: A8      TAY        YレジスタにAレジスタがコピーされる
  $6A27: D3 14     DCM ($14),Y   ($14)+Y=$6A2Eを1減らす
  $6A29: 4B 4B     ALR #$4B  
  $6A2B: 4B 00     ALR #$00  
  $6A2D: 20 23 6A  JSR $6A23    $6A23へジャンプ
  -----
  $6A23: C8      INY         Yレジスタが+1される
  $6A24: 4B 4B    ALR #$4B    Aレジスタが0になる
  $6A26: A8      TAY        YレジスタにAレジスタがコピーされる
  $6A27: D3 14     DCM ($14),Y   ($14)+Y=$6A2Eを1減らす
  $6A29: 4B 4B     ALR #$4B  
  $6A2B: 4B 00     ALR #$00  
  $6A2D: 20 22 6A  JSR $6A22    $6A22へジャンプ
  -----
  $6A22: EC C8 4B  CPX $4BC8   $4BC8とXレジスタを比較
  $6A25: 4B A8    ALR #$A8
  $6A27: D3 14     DCM ($14),Y   ($14)+Y=$6A2Eを1減らす
  $6A29: 4B 4B     ALR #$4B  
  $6A2B: 4B 00     ALR #$00  
  $6A2D: 20 21 6A  JSR $6A23    $6A21へジャンプ
  -----
  $6A21: 20 EC C8  JSR $C8EC   コントローラ情報を取得し$14-$15を更新するサブルーチン
  $6A24: 4B 4B     ALR #$4B     $C8ECの後、0x10になっているAレジスタが0x00になる
  $6A26: A8      TAY        YレジスタにAレジスタがコピーされる
  $6A27: D3 14     DCM ($14),Y   ($14)+Y=$6A2Eを1減らす
  $6A29: 4B 4B     ALR #$4B  
  $6A2B: 4B 00     ALR #$00  
  $6A2D: 20 21 6A  JSR $6A21    $6A21へジャンプ
  -----

 このようにループ変更途中にC8:INYが実行されてしまうのでA8:TAYでYレジスタを0に戻してやっています。 また、JSR $C8EC のサブルーチンから戻って来た後はAレジスタが0x10になるので ALR #$4B によってAレジスタが0になるようにしてます。
 Yレジスタがずれたままだと$6A2Eではなく$6A2Fを減らすことになってループ先が$6923になってしまいます。 それ自体は4Bゾーンを通った長めのループになるだけなので問題なさそうですが、一回のループが長いとVBlankが発生した時にループが途切れる可能性が増えます。

 これでようやく任意コード実行の最終形態とも呼べるであろうトータル・コントロールが可能になったので、メモリをモリモリ減らしていきましょう。
 動画では10フレームしか表示されなかった部分を細かいサブフレームインプットまで表示するとこんな感じになります。
 $14の数値に対応したアドレスが1ずつ減らされていくのが見てわかると思います。$6921から書いてるコードはエンディングへ飛ぶためのもので、書き終わった後に$6A2Fの値を1つ減らしてジャンプ先を$6A21から$6921に変更して最後の操作を終了しています。
 その直前にはフラグ操作も行っていて、0人PT時の動けないフラグ$058F[10][0F]に変更し、エンディング途中でメッセージが発生すると入力が必要になるので、コーリンゲンで止まらないように$627E$62830xFFにしてます。しかし、実は$62A1[20]のエンディング待機フラグあればその2つは必要ないので1フレーム未満の差ですが本当はそっちの方が早いですが、通常のエンディングを見るよりも特殊なエンディングの方が価値が高いと思ったのでこちらを採用。

エンディングに飛ぶプログラムについては以下の通り

  $6921: 49 1A     EOR #$1A    Aレジスタ排他的論理和で0x1Aにする
  $6923: 20 91 FF   JSR $FF91    $FF91のサブルーチンでバンクを0x1Aに切り替える
  $6926: 20 FC 9B   JSR $9BFC   エンディング処理の途中である$9BFCにジャンプ

 Aレジスタを変更する命令は49:EOR以外にも沢山ありますが、4Bから近いので採用。
 バンク切り替えというものはROMが読み込んでる範囲を変更するものです。 CDで言えばディスクチェンジを一瞬でしてるようなものだと思うと合ってるかもしれませんし間違ってるかもしれません。 グラフィックが読み込まれる範囲を動的に変更したりも出来るので、バンクが間違った状態だとグラフィックも崩れたりします。
 DQ4の場合は$8000~$BFFFと$C000~$FFFFの部分にプログラムが書かれてますが、前者は細かく切り替えられながらプログラムを実行しています。 その中でエンディング処理が書かれているのは 1A:9BFC でそのままだとバンクが違うのでJSR $9BFCを実行してもエンディングへは飛べないのでバンク切り替えを行ってからジャンプしています。


 最後に、スタックが溢れてないか気になってる人がいると思うのでGIFを貼っておきます。












 
 エンディング中はイベント処理を実行し続けてサブルーチンから戻ってこないので、
スタックは溢れまくりましたがエンディングが見れたので問題ありません。