[IPSwitch] 誰でもできるコード開発 #8

Hack

チーム変更コード

チーム変更コードとは試合中に自分のプレイヤーの属するチームを変更するパッチのことで、スプラトゥーンのゲームは試合中にチームが切り替わることなんて想定していないのでへんてこな現象が起きたりします。

Starlightによる実装

Starlightによるチーム変更コード

Starlightにはコントローラの入力を取得するクラスCollector::mControllerがあるので、これを利用することで任意のタイミングで好きなコードを実行できます。

ところが3.1.0以降のバージョンはStarlightが動作しないので、好きなタイミングでコントローラの入力を取得してコードを実行することはできません。

IPSwitchによる実装

任意のタイミングでキー入力をすることはできないのですが、似たような動作をIPSwitchを使って再現することは可能です。

それが前回の講座で紹介したナイスやカモンのシグナルをHookしてナイスの動作を別の命令に上書きしてしまうというものでした。

ナイス自体は試合中であればいつでも呼び出せるので、Starlightによるキー入力を再現することができるのです。

チーム変更コードの仕組み

Starlightによるチーム変更の擬似コードは以下のようになります。

実際にはインスタンスのNULLチェックを行わないとクラッシュします。

Game::PlayerMgr *mPlayerMgr = Collector::mPlayerMgrInstance;
Game::Player *mPlayer = mPlayerMgrS->getControlledPerformer();
if (Collector::mController.isPressed(Controller::Buttons::UpDpad))
    mPlayer->mTeam ^= 1;

チーム情報を保存しているデータはCmn::Actor->mTeamで、これは0, 1, 2のいずれかの値を取ります。

mTeam
0Alpha
1Bravo
2Neutral
mTeamの意味

NeutralはAlphaでもBravoでもないチームで、観戦者などが割り当てられます。

ちなみにNeutralにはインクの色属性がないので、チームをNeutralに変更してからインクの飛沫を発生させるとゲームがクラッシュします。

サーモンランではプレイヤーは常にAlphaチームで、Bravoにはシャケが割り当てられているよ。

本来であればCmn::Actor->mTeamにアクセスするためにはCmn::Actorのポインタを調べなければいけないのですが、Game::PlayerクラスはCmn::Actorクラスを継承しているので、Game::Playerクラスのインスタンスを見つければCmn::Actorのアドレスはすぐに見つけることができます。

Game::Playerクラス

Game::Playerクラスがどのような構造をしているかはStarlightのソースコードを見ればわかります。

今回は誰が見てもわかりやすいように以下の画像にまとめました。

Game::Playerクラスの概要

本当はもっと大きいクラスなのですが、使いそうなのはせいぜいCmn::PlayerInfoクラスまでだとおもうのでここまでにとどめました。詳しく知りたい方はソースコードを読んでください。

さて、ここからわかるのはGame::Playerクラスのインスタンス(ポインタ)がわかれば、そこから0x328だけズラしたところにチーム情報を格納する値が存在するということです。

Game::Playerクラスのインスタンスを呼び出すコードを書けばいいのですが、実はGame::Playerクラスは前回の記事で紹介したようにCmn::Singleton::GetInstance_(void)::sInstanceで呼び出されているわけではないのです。

ではどうすればいいのかということなのですが、Game::Playerクラスを司っているGame::PlayerMgrクラスを利用するのです。

インスタンスのアドレス [3.1.0]

インスタンスのアドレスを調べるのは適当に検索をかければいいのですが、今回はあらかじめ調べたものをご紹介します。

本講座の趣旨は与えられた情報からコードをつくることであって、情報を調べるところは省略しています。

というのも、誰かがすでに見つけている情報を「あなたも見つけてください」っていうのは単純に時間の無駄だから。ぼくは秘密主義ではないのでそんな無駄なことをさせるつもりはありません。

サブルーチンアドレス
sendSignalEvent()0x00E797FC
Game::PlayerMgr::sInstance0x04157578

sendSignalEvent()に関しては前回と同じです。

インスタンスを呼び出す [3.1.0]

インスタンスを呼び出すためのテンプレートがあることは前回の記事で紹介しました。おさらいとしてもう一度復習しましょう。

ADRP X0, #0xXXXXX000
LDR  X0, [X0, #0xYYY]
LDR  X0, [X0]

この三命令でX0レジスタに呼び出したいインスタンスのポインタが入ります。

目的アドレスHookアドレス(固定)
0x041575780x00E797FC

つまり、目的アドレス(今回の場合はGame::PlayerMgrのアドレス)とHookしたいサブルーチンのアドレス(ナイスをHookするのであれば毎回同じ値)からXXXXXとYYYの値を求めればよいのです。

XXXXXの求め方

目的アドレスとHookアドレスの下三桁を全て0にし、目的アドレス-Hookアドレスの計算結果がXXXXXになります。

これはWindows標準の電卓で簡単に計算することができます。

0x04157000 - 0x00E79000 = 32DE000

YYYの求め方

目的アドレスの下位三桁なので578になります。

テンプレートを完成させる

さて、テンプレの命令セットに当てはめると以下のコードができます。

ADRP  X0, #0x32DE000
LDR   X0, [X0, #0x578]
LDR   X0, [X0]

これで無事にX0レジスタにPlayerMgrのインスタンスのポインタが取得できています。

次にPlayerMgrから自分が操作するプレイヤーのGame::Playerインスタンスを取得します。

そうしないと自分でないプレイヤーのデータを弄ってしまうことになるからな。

自分の操作するプレイヤーのGame::Playerインスタンスを取得するサブルーチンとしてGame::PlayerMgr::getControlledPerformer()があるのでこれをつかいます。

サブルーチンの呼び出し方

さて、ここで問題となるのはサブルーチンの呼び出し方です。

ARM64命令を見ればわかりますが、関数呼び出しはBL命令を使って実装されています。

// C++
int main() {
  Game::Player *mPlayer = Game::PlayerMgr::getControlledPerformer();
}

// ARM64
ADRP  X0, #0x32DE000
LDR   X0, [X0, #0x578] // Game::PlayerMgr*
LDR   X0, [X0] // this (Game::PlayerMgr sInstance)
BL    _ZNK4Game9PlayerMgr22getControlledPerformerEv

このC++擬似コードとARM64命令は等価だぞ。

BL命令というのは簡単にいえばジャンプ命令で、ジャンプした先のアドレスの命令を実行したあとでRET命令でBL命令の次の命令を実行します。

BL命令の仕組み

sendSignalEvent側ではX0レジスタは全く触れていませんが、BL命令でジャンプした先のgetControlledPerformerが返り値をX0レジスタにいれているため、このコードは正しく動作します。

サブルーチン呼び出しで注意すること

プログラムを書く上では全く意識しないことなのですが、全ての関数(サブルーチン)には引数が必要です。

// C++
class Game::PlayerMgr {
    Game::Player* getControlledPerformer();
}
 
int main() {
  Game::Player *mPlayer = Game::PlayerMgr::getControlledPerformer();
}

例えば上の疑似コードはgetControlledPerformer()はカッコの中が空っぽなので引数はないようにみえますが、実際には自分自身を引数としてとっています。

なので、本来は以下のように定義されます。

// C++
class Game::PlayerMgr {
    Game::Player* getControlledPerformer(Game::PlayerMgr * __hidden this);
}
 
int main() {
  Game::Player *mPlayer = Game::PlayerMgr::getControlledPerformer();
}

要するに見えない引数(0番目の引数)として自分自身のポインタをとっているので、BL命令をコールする際には必ずX0レジスタ(これが0番目の引数で、1番めの引数はX1レジスタとなる)にBL命令ジャンプ先で実行されるサブルーチンのインスタンスのポインタが入っていなければいけません。

getControlledPerformer()はGame::PlayerMgrクラスのサブルーチンなので、Game::PlayerMgrクラスのインスタンスをX0レジスタに読み込んでおかなければいけなかったというわけです。

// OK
ADRP  X0, #0x32DE000
LDR   X0, [X0, #0x578] // Game::PlayerMgr*
LDR   X0, [X0] // this (Game::PlayerMgr sInstance)
BL    _ZNK4Game9PlayerMgr22getControlledPerformerEv

// NG
ADRP  X0, #0x32EC000
LDR   X0, [X0, #0xDB8] // Game::Coop::PlayerDirector *
LDR   X0, [X0] // this (Game::Coop::PlayerDirector sInstance)
BL    _ZNK4Game9PlayerMgr22getControlledPerformerEv

上のコードはちゃんとX0レジスタにPlayerMgrのインスタンスが入っていますが、下のコードはPlayerDirectorのアドレスが入っているのでBL命令を実行しようとするとクラッシュします。

BL命令の書き方

BL命令はインスタンスのアドレスを読み込むのと違ってオフセットがないため少しややこしいです。

今回のケースですと、ジャンプしたいアドレスというのはgetControlledPerformer()のアドレスですので、まずこの値を調べます。

また、BL命令をコールするアドレスですが、今回は0x00E79810とします(本来この値はBL命令を書く場所によって変わります)

BL命令をコールするアドレスgetControlledPerformer()
0x00E798100x00F07B1C
BL命令の書き方

ここまでわかれば、BL命令を書くのは簡単です。ARM64命令は優しいのでいちいち差を電卓で計算しなくても以下のように書くことができます。

// ARM64
BL    #0xF07B1C - 0xE79810

これでアドレス0xE79810から0xF07B1CへジャンプしてgetControlledPerformer()をコールするBL命令が書けました。

BL命令を呼ぶ前には必ずX0レジスタに呼び出したい関数のインスタンスのポインタが入っていなければいけなかったので、ここまでをまとめると以下のコードが完成します。

ADRP  X0, #0x32DE000
LDR   X0, [X0, #0x578] // Game::PlayerMgr*
LDR   X0, [X0] // this (Game::PlayerMgr sInstance)
BL    #0xF07B1C - 0xE79810

チームを変更しよう

さて、BL命令が実行されたことによりX0レジスタにはGame::Playerのインスタンス(自分が操作するプレイヤーのもの)が入っています。

Game::Playerクラスの中身

チーム情報はGame::Playerクラスの先頭から0x328番目に入っているので、この値を取得する必要があります。X0に入っているのはポインタなので、データを取得するにはLDR命令を使う必要があります。

この0x328番目に入っているという情報はたかはる氏のコードを参考にさせていただきました。

ADRP  X0, #0x32DE000
LDR   X0, [X0, #0x578] // Game::PlayerMgr*
LDR   X0, [X0] // this (Game::PlayerMgr sInstance)
BL    #0xF07B1C - 0xE79810 // Game::Player *mPlayer = Game::Player::Player()
LDR   X0, [X0, #0x328] // X0 = mPlayer[0x328]

これでX0レジスタにチーム情報であるmTeamのデータを読み込むことができました。

レジスタの割り当てを考える

ただ、ここまでコードを書いてみてX0レジスタを何度も呼び出しているので全くデータを保存できていないことがわかります。

X0レジスタの値が上書きされ続けてしまっているのだ。

X0レジスタは本来、インスタンスのポインタを指すために使われるべきであり(そうしておけば非常にわかりやすい)何度も繰り返し使われるのは避けるべきです。

特に何か決まりがあるわけではないのですが、X8レジスタあたりからデータを保存することに使われるみたいなので(スプラのコードを読んだ限りはそんな気がするだけで根拠なし)、以下のようにコードを書き換えます。

ADRP  X8, #0x32DE000
LDR   X8, [X8, #0x578] 
LDR   X0, [X8] // this
BL    #0xF07B1C - 0xE79810
LDR   X8, [X0, #0x328]

こうすればX0レジスタには常にポインタが入っていることが明らかで、可読性が遥かに向上します。

さて、次に読み込んだデータを変更したいのですがmTeamの値は基本的には0か1が入っていることを思い出してください(2は観戦者モード)

となると、チーム変更するためには保存されている値が1だったら0を、0だったら1を返すコードが必要になります。

// C++
if (X0 == 1) 
  return 0;
if (X0 == 0)
  return 1;

これをC++擬似コードで表すと上のようになるのですが、実はARMでIF文を実装しようとするとコスト(命令数がたくさん必要)でやっかいです。ここはIF文を使わずに出力することを考えましょう。

ARM64にビット反転の演算があればそれを利用すればいいのですが、ドキュメントを探しても見つからなかった(検索力不足かも)ので別の演算で代用します。

現在のチーム情報はX8レジスタに入っており、その値はほとんどの場合で0か1のどちらかです。X8レジスタを反転させるNOT X8(X8レジスタの値を反転させる)のような命令はないのですが論理演算命令はいくつか実装されているので使えないか検討してみます。

NOT演算

NOT演算があれば1ならば0、0ならば1が出力できます。

NOT0を入力1を入力
1を出力0を出力

が、この命令はないのでこれは実装できません。

AND演算

AND0を入力1を入力
0と比較00
1と比較01

AND演算は二つの入力がどちらも1であれば1を返す論理演算ですが、これでは上手く反転させることができません。

OR演算

AND0を入力1を入力
0と比較01
1と比較11

OR演算は入力のどちらかが1であれば1を返す論理演算ですが、これもやはりそのままの値が出力されるか、どちらも1を返すかになってしまうのでダメです。

ORN演算

AND0を入力1を入力
0 -> 1と比較11
1 -> 0と比較01

ORN演算はXnレジスタとXmレジスタを反転させた値で論理和を求めます。いろいろ使い勝手のいい論理演算ですが、今回の場合はOR演算と同じ結果になってしまうので使えません。

XOR演算

AND0を入力1を入力
0と比較01
1と比較10

XOR演算は排他的論理和といわれる論理演算です。

ARM64ではEOR命令なのですが、今回は馴染みの深いXOR演算として紹介します。

この演算は(ひどく大雑把にいえば)比較する二つの値が同じなら0、異なれば1を返します。ということはXOR演算を用いて0と比較した場合には、

(Constant, Input) => Output
(0, 0) => 0
(0, 1) => 1

となるので0なら0、1なら1を返してしまい意味がないのですが、1と比較する場合には、

(Constant, Input) => Output
(1, 0) => 1
(1, 1) => 0

となり、ビット反転を擬似的に実装できることがわかります。

変更した値を反映させるにはLDR命令の逆であるSTR命令を使えばいいので、ここまでをまとめると以下のようになります。

ADRP  X8, #0x32DE000
LDR   X8, [X8, #0x578] 
LDR   X0, [X8] // this
BL    #0xF07B1C - 0xE79810
LDR   X8, [X0, #0x328]
EOR   X8, X8, #1 // x8 = ~X8
STR   X8, [X0, #0x328]

コールスタック

ここまでできたのであれば「あとはARM to HEX ConverterでHEX化して終わりじゃないの?」って思う方もいるかも知れませんが、ここで最後のトラップであるコールスタックが残っています。

ここで、オリジナル状態のsendSignalEvent()のARM命令を見返してみましょう。先頭三行に何をしているのかよくわからない命令があると思います。

// Game::PlayerCloneHandle::sendSignalEvent()
STR    X19, [SP,#var_20]!
STP    X29, X30, [SP,#0x20+var_10]
ADD    X29, SP, #0x20+var_10

これこそがコールスタックを実装している部分で、サブルーチン内にBL命令があるのであれば必ず必要になります。なんで必要になるかは細かく解説しているとこの記事の長さが倍になるので省略します。とりあえず、サブルーチン内にBL命令があるときは必ず書かなければいけないと覚えておいてください。

これを書かないとBL命令後にプログラムカウンタが正しい位置に戻らず、スプラトゥーンがフリーズしてしまいます。

コールスタックの書き方

コールスタックの書き方ですが、サブルーチンにいくつBL命令を書くかで変わってきます。

これを書き忘れててずっとフリーズし続けていたのはナイショです。

BL命令が一つの場合

命令が一つだけの場合、以下のようにコールスタックを実装します(ここでは意味がわからなくても構いません)

STP X29, X30, [SP, #-0x10]!
MOV X29, SP
// ARM64 CODE
LDP X29, X30, [SP], #0x10
RET

サブルーチン開始直後に二つ、RET命令の直前に一つ合計四命令だけ余計にコードを書いて実装します。

BL命令が二つの場合

STR X19, [SP, #-0x20]!
STP X29, X30, [SP, #0x10]
ADD X29, SP, #0x10
// ARM64 CODE
LDP X29, X30, [SP, #0x10]
LDR X19, [SP], #0x20
RET

前後にそれぞれ一命令ずつ増えて全部で六命令となります。

前回の記事でコールスタックを上書きしても正しくコードが動いたのは、上書きしたコードの中にBL命令がなかったためです。

今回は使っているため、このコードを書く必要があるというわけです。

コードをまとめる

今回はBL命令が一つだけなので、その場合のコールスタックのテンプレートを使ってここまでのコードを全てまとめると以下のようになります。

STP X29, X30, [SP, #-0x10]!
MOV X29, SP

ADRP  X8, #0x32DE000
LDR   X8, [X8, #0x578] 
LDR   X0, [X8]
BL    #0xF07B1C - 0xE79810
LDR   X8, [X0, #0x328]
EOR   X8, X8, #1
STR   X8, [X0, #0x328]

LDP X29, X30, [SP], #0x10
RET

あとはこのコードをGame::PlayerCloneHandle::sendSignalEventに対して上書きすれば良いのでアドレスも考えると以下のようになります。

00E797FC STP X29, X30, [SP, #-0x10]!
00E79800 MOV X29, SP
00E79804 ADRP X8, #0x32DE000
00E79808 LDR X8, [X8, #0x578]
00E7980C LDR X0, [X8]
00E79810 BL #0x8E30C // BL #0xF07B1C - 0xE79810
00E79814 LDR X9, [X0, #0x328]
00E79818 EOR X9, X9, #1
00E7981C STR X9, [X0, #0x328]
00E79820 LDR X1, [X0, #0x488] 
00E79824 STR X9, [X1, #0x38]
00E79828 LDP X29, X30, [SP], #0x10
00E7982C RET 

ここで、なぜ先ほどBL命令の説明をしたときに0x79810を使うと決めたかがわかると思います。

あとはこれらのコードをARM to HEX Converterで変換するだけです。

ただ、既存のARM to HEX Converterにはバグ(それともややこしい仕様?)があり、BL命令を変換すると変なオフセットがつけられてしまいます。

ARM to HEX Converterの謎のバグ

同じ命令を三回書いているのに、ARM64の変換結果が毎回違うというおかしなことになります。この場合、正しいのは一番上のコードです。

// Swap Team Color by Signal [tkgling]
@disabled
00E797FC FD7BBFA9
00E79800 FD030091
00E79804 E89601D0
00E79808 08BD42F9
00E7980C 000140F9
00E79810 C3380294
00E79814 099441F9
00E79818 290140D2
00E7981C 099401F9
00E79820 014442F9
00E79824 291C00F9
00E79828 FD7BC1A8
00E7982C C0035FD6

// Swap Team Color by Signal [tkgling]
@disabled
00E797FC FD7BBFA9FD030091
00E79804 E89601D008BD42F9
00E7980C 000140F9C3380294
00E79814 099441F9290140D2
00E7981C 099401F9014442F9
00E79824 291C00F9FD7BC1A8
00E7982C C0035FD6

上の二つはどちらも等価なコードですが、可読性をとるなら上のコードを、利用するだけなら下のコードを使えば良いと思います。

ナイスを押せばチームが変わるコード

ナイスって書いてあるけど、実はカモンでも変わります。

演習問題

さて、今までの解説は全て3.1.0向けでしたが、それぞれのインスタンスやサブルーチン自体は5.2.0でも有効なので移植することができます。

シンボル名が削除されてしまったのでサブルーチンやインスタンスのアドレスを調べるのは面倒ですが、既に調べたものを以下に載せておきます。

アドレス
Game::PlayerCloneHandle::sendSignalEvent0x01042818
Game::PlayerMgr::sInstance0x02CF3910
Game::PlayerMgr::getControlledPerformer0x010DCBF8

これらの情報を使って、Swap Team Color by Signalを5.2.0向けに移植してください。

実はここまでのまどろっこしいことをしなくても擬似的に実装する方法はあるんですが、ここでは省略します。

記事は以上。

コメント

  1. 匿名 より:

    勉強になります(と言いつつ実は全然分かってないですけどw)
    ところで、この「チーム変更コード」は他の非改造機とローカル通信で遊んでいる時も動作するんですかね?
    だとしたらバトルの進行をかき乱す第三勢力として活躍できそうですねw

    • えむいー より:

      非改造機と通信していても動作しますが、非改造機と同じチームの状態でコードを実行するとクラッシュします(原因についてはよくわかっていません)。回避可能かもしれませんが、そうするとオンラインで使えてしまうので仮に見つかっても公開しない可能性があります。

タイトルとURLをコピーしました