Contents
Ocean Calcとは
Ocean Calcとは初期ゲームシードからサーモンランのWAVEの内容を全予測しようというプロジェクトである。
初期ゲームシードから何が予測できるのかは多分、当ブログでもう十回以上説明してきたと思うのだが文字数が稼げるので何度でも説明する。
内容 | 進捗 | 解析者 |
イベント内容 | 完全解析 | @container12345 |
潮位 | 完全解析 | @container12345 |
キンシャケ探しアタリ位置 | 完全解析 | @container12345 |
キンシャケ探しゴール位置 | ほぼ解析 | @container12345 + @tkgling |
出現するオオモノの種類 | 完全解析 | @tkgling |
上限時の代替オオモノ | 未解析 | – |
ランダム枠で支給されるブキ | 未解析 | – |
支給されるスペシャル | 未解析 | – |
オオモノが出現する湧き方向 | 今回の内容 | – |
ラッシュイベントのターゲット | 未解析 | – |
霧イベントの金イクラドロップ数 | 解析中 | @tkgling |
ゴール位置が解析できていないのはアタリ位置との組み合わせを検証するのがめんどくさい割に、得られるリターンが全然ないというのが大きい。なぜなら、金イクラを稼ぐためにはゴール位置よりもアタリ位置のほうがずっと重要だからである。
なので、やれば誰でも解析できるのでやりたい方がいたら連絡ください。ただ、ドンブラコだけは別アルゴリズムになっているらしく、アタリ位置は完全解析できるもののゴール位置が変な感じになっています。
霧イベントのドロップ数
霧イベントの金イクラドロップ数についてはバージョン3.2.0を解析することである程度わかっています。
少なくとも、ドロップ数を制御する疑似乱数を生成する擬似乱数生成器がEnemyDirector
クラスの0x724
にあることは間違いないです。
ただ、この生成器が「いつ」「どこで」「何によって」初期化されているかがわからないため「生成されている乱数」がわからず、そのために現時点でドロップ数を予測することができません。
解析を困難にしている最大の理由は3.2.0でStarlightが動作しないことなので、簡単そうに見えて意外と難しいのかも知れません。
霧イベントで100納品をするためには全キンシャケが10個ドロップする必要があり、その確率がおよそ1/81しかないので全通りを試していくのは流石にめんどくさいですね。
出現数オオモノの種類
前回解析したのがこのオオモノの種類を決定するアルゴリズムで、これによって出現するオオモノが簡単なWAVEを厳選することができるようになりました。
例えば、ドスコイ大量発生は誰か一人がキャノンにのる必要があるためカタパッドやタワーが出現するとそれだけで全回収が不可能になりがちな夜イベントです。
ですが、先に計算してカタパッドもタワーも出現しないWAVEを選べばこのように簡単に72納品を達成することができるというわけです。
しかし、これだけではまだ不十分で如何に簡単なWAVEであっても湧き方向が悪いとオオモノの寄せが難しかったり、満潮トキシラズでの対岸のように回収が難しい湧き位置をされるとオオモノの種類が良かったとしても難しくなるわけです。
そこで求められたのが、湧き方向位置を計算するアルゴリズムの完全解析でした。
湧き方向位置計算アルゴリズム
実は、どの関数が湧き方向を決めているかはわかっていてそれがGame::Coop::EnemyDirector::calcEnemyAppearId_()
というところまでは突き止められていました。
この関数はサイズが0x188で引数にシード値とある定数をとり、出力として1, 2, 3のいずれかの湧き方向を返します。
これまでの解析からシード値ゲームシードから生成されるWAVEシードであることはすぐに分かったのですが、定数の値がなにを取るのかがわからないままでした。
これについては更に解析を進めGame::Coop::EnemyDirector::changeEnemyAppearId()
の引数がそのまま使われていることがわかり、Game::Coop::EnemyDirector::changeEnemyAppearId()
はWaveHandler
クラスで呼び出されていることもわかりました。
となれば、WaveHandler
クラス内で実際に呼び出されているところを探して、どんな引数をとっているか調べれば良いことになります。
WaveHandlerクラス
Game::Coop::EnemyDirector::changeEnemyAppearId()
が呼び出されているのは三箇所しかなく、それぞれsetup()
、applyWaveChange()
、updateWaveChange()
となります。
すると、その全てで以下のような命令が書かれていました。
LDR X0, [X19] ; this MOV W1, #0xFFFFFFFD ; int BL _ZN4Game4Coop13EnemyDirector19changeEnemyAppearIdEi ; Game::Coop::EnemyDirector::changeEnemyAppearId(int)
これはつまり引数として#0xFFFFFFFD
という値が使われていることになります。
こんな大きな数なにに使うんだと思うかも知れませんが、それは符号なし整数の場合であり符号あり整数であればこの値は-3を意味します。
つまり、少なくともGame::Coop::EnemyDirector::calcEnemyAppearId_()
は引数として-3をとることがわかりました。
他のクラスで調べてみる
実はGame::Coop::EnemyDirector::calcEnemyAppearId_()
は他のクラスでも使われており、特にサーモンランのチュートリアルで複数回呼び出されていました。
その際には引数として1, 2, 3(正の数である!)が使われていました。
このときは「先程は-3をとっていたのに、突然正の数になるとはどういうことなんだろう」と正直思っていました。
この不思議については最終的に解決するのですが、なかなか興味深かったです。
Game::Coop::EnemyDirector::calcEnemyAppearId_()
この関数は引数の定数の値によって条件分岐をします。
定数の値 | 返り値 | 呼び出し元 | |
-3 | 乱数を消費して計算 | WaveHandlerクラス | |
-2 | 値を変更しない | 不明 | |
-1 | 乱数を消費して計算 | 不明 | |
それ以外 | ? | チュートリアル |
コードを読み解いた限り、この関数は引数として-2や-1が入力されることも想定していると思うのですが、調べた限りでは引数としてそれらの数値をとっている箇所は見つかりませんでした。
となると-3かそれ以外ということになるのですが、サーモンランでプレイしているときに湧き位置をするのは-3なのかそれ以外なのかという点が気になってくるわけです。
乱数消費回数で調べる
そこで、以下のようなIPSwitchのコードを作成しました。
Starlightをもってしても全ての瞬間のレジスタの中身を表示することはできないのですが、乱数が消費されたかどうかは乱数生成器をチェックすればわかります。
そして湧き位置変化は乱数を消費して乱数生成器の内部状態を変化させることに注目するのです。
// Disabled RNG -1 @enabled 0054A730 000080D2 // Disabled RNG -3 @enabled 0054A7E8 000080D2
これらのコードはGame::Coop::EnemyDirector::calcEnemyAppearId_()
の引数の値によって条件分岐した際に乱数生成器使わずに変わりに0を使うようにするコードです。
乱数の消費しないので乱数生成器の内部状態がズレないというわけですね。
例えば、上のコードだけ有効化すれば「引数が-1であれば乱数を消費しない」ということになり、これを有効化した状態で乱数生成器の内部状態をチェックして内部状態が本来よりもN回変化していなければ「引数-1として湧き方向の計算がN回行われた」ということがわかるわけです!
そしてこれらを使って確かめたところ「湧き方向を変化をする際には引数として-3しかとらない」というところまで突き止めることができました。
この検証法思いついて実際にやるまでめっちゃだるかった!!!!
逆アセンブルしよう
全部貼るとすごく長くなってしまうので、肝心の部分のソースコードのリンクを貼っておきます。
湧き方向は毎回必ず1で初期化され、湧き方向数も全ステージ(多分)共通で最大3となります。
実は同じ湧き方向であっても微妙に違い(三つある桟橋のどれからでてくるか、のような)があるのですが、その差がどこから生じているのかはまだわかりません。
アセンブラをただそのままJavascriptに移植しただけなので冗長なコードになっていますがgetAppearId()
が湧き方向を計算するアルゴリズムになります。
そして、これを解析する上で引数の違いについても理解することができました。
定数の値 | 返り値 | 呼び出し元 | |
-3 | 乱数を消費して計算 | WaveHandlerクラス | |
-2 | 値を変更しない | 不明 | |
-1 | 乱数を消費して計算 | 不明 | |
それ以外 | その方向に変化 | チュートリアル |
つまり、正の数を代入すると「乱数を消費せずにその方向に湧き方向を変化させる」というアルゴリズムだったというわけです。
チュートリアルでは全てのプレイヤーに対して常に同じ内容を提供したいので「湧き方向を指定すればその方向からオオモノが出現するようなプログラムにする」というのは至極当然の考えになります。
なのでチュートリアルでだけ正の数を引数としていたわけですね!
詰まったところ
実はJavascriptへの移植は早い段階でできていたのですが、プログラムミスで常に同じ値を返すバグが発生していました。
以下、戒めとしてその理由を解説。
x12 = x9 == v5 ? 5 : x8 == 0
ここの想定している動作としては「x9 == v5なら5、そうでなければx8==0なら1、そうでなければ0をx12に代入」となるわけです。つまり、0か1か5のどれかの値が入るわけですね。
で、C++ならこれでいいと思うのですがJavascriptだとエラーが発生します。というのも、x8 == 0
の演算結果は1か0ではなくtrue
かfalse
で返ってくるからです。
なので、このあとで例えば10 - x12
みたいなコードを書くとundefined
が返ります。ところがこのアルゴリズムは「x12がある条件を満たしたらループを抜ける。最後まで抜けなければある定数を返す」というプログラムになっていたので「毎回最後まで抜けずに定数を返すプログラム」として正しく(誤った)動作しており、エラーに気づくことができませんでした。
で、途中で怪しいなあと思って以下のように書き直したのですが、これもエラーを引き起こしていました。
x12 = x9 == v5 ? 5 : parseInt(x8 == 0)
parseInt()
がtrueなら1、falseなら0を返してくれるかと思ったのですがこれもやはりundefinedしか返しませんでした。
正しく動作するためにはキチンと返り値を明示する必要があります。
x12 = x9 == v5 ? 5 : x8 == 0 ? 1 : 0
こうすることで正しくx12が計算でき、その結果として湧き方向を正しく求めることができるようになりました。
今後の展望
湧き方向を求めることができるようになったので、全ステージの全潮位における湧き方向のマッピングを作成しようと思う。
Starlightがあれば値はガンガン変えられるので一時間もあれば調べられそうな気はします。
ラッシュやグリルのときも同じ値が使われるのかとかは気になったりするので調べてみたいですね。これらのときはひょっとするとターゲットを決定するために乱数が使われていて、ズレているかも知れないので。
あとはこれを利用して通常ポラリスで72納品できそうなWAVEを探すのも面白そうかなあと考えています。
記事は以上。
自身を天才と信じて疑わないマッドサイエンティスト。二つ上の姉は大英図書館特殊工作部勤務、額の十字架の疵は彼女につけられた。
コメント