えいむーさんは明日も頑張るよ

オオモノ湧きアルゴリズムを解析した

価格

# サーモンラン完全解析

バイト開始時の初期シードから計算できる全てのパラメータを求めるアルゴリズムを解析しようという試みです。

現在のところ、以下の要素が初期シードから計算可能であることがわかっており、いくつかのものについては@container12345 (opens new window)氏により完全解析されています。

パラメータ 進捗 クラス
潮位 完全解析済 Game::Coop::Setting
イベント 完全解析済 Game::Coop::Setting
キンシャケ探しアタリ位置 完全解析済 Game::Coop::EventGeyser
キンシャケ探しゴール位置 ほぼ解析済* Game::Coop::EventGeyser
ランダム支給ブキ 未解析 未調査
支給スペシャル 未解析 未調査
オオモノ湧き順 未解析 Game::Coop::EnemyDirector
シャケ湧き方向変化 未解析 Game::Coop::EnemyDirector
ラッシュイベントのターゲット 未解析 Game::Coop::EnemyDirector
霧イベントの金イクラ 未解析 未調査

解析について

オオモノの湧き順とシャケの湧き方向変化については解析が完了しました。

代替オオモノについてはわかっていない点が多いものの、再現が困難なので解析する予定は今のところありません。

難破船ドンブラコについて

キンシャケ探しゴール位置は難破船ドンブラコと通常潮と満潮での特定の一箇所のゴール位置のみが計算できていません。これは 3.2.0 からキンシャケ探しイベントにおいてドンブラコのみ別の計算式が使われるようになったためです。

逆に言うとこれ以外のものは初期シードから簡単に決まらない可能性が高いです。ハコビヤイベントのシャケコプターの飛来位置やグリルイベントのターゲットも初期シードから決まっているはずなのですが、リトライしても同じ状態が再現されないためです。

# 前回までの進捗

さて、前回はGame::Coop::EnemyDirectorクラスが二つの乱数生成器をもち、一方はザコシャケの計算に使い、もう一方はオオモノ出現と湧き方向変化に使われていそうだという話をしました。

オオモノの湧きを完全解析するのであれば、乱数消費が「オオモノの湧き」によるものなのか「湧き方向の変化」によるものなのかを区別しなければなりません。

しかし、湧き方向変化はキケン度 MAX とたつじん 80%では 8、それ以外では 6 という風になっているため、一律に定義することができません。なので今回は湧き方向変化を 8 回と決め打ちしてプログラムを組むことにしました。

WAVE の潮位とイベントの検索アルゴリズムも、実はたつじんとそれ以下では異なっています。でもアルゴリズムはたつじん以上にしか対応していません。それと同じように、本アルゴリズムもあくまでもキケン度 MAX の湧きを計算するものにします。

# 逆アセンブラの擬似コードを解析する

do {
  v78 = v11;
  v12 = Game::Coop::EnemyRareType::getArray_(v8);
  if (*v12 <= v78)
    v13 = *(v12 + 1);
  else
    v13 = (*(v12 + 1) + 4L * v78);
  v75 = *v13;
  v8 = Game::Coop::EnemyDirector::getEnemyActiveMax_(this, &v75);
  if (v75 >= 0x17)
    v14 = &this->dword6A0;
  else
    v14 = (&this->dword6A0 + v75);
  if (v8 > *v14)
  {
    ++v10;
    v8 = sead::Random::getUe32(&v83);
    if !(v8 * v10 >> 0x20)
      v84 = v75;
  }
  if ( v11 <= 6)
    ++v11;
  else
    v11 = 6;
}
while ( v11 != v9);
if ( v10 )
  v85 = 1;

最初に逆アセンブラから得られた生コードがこれでした。神プログラマはこれでもいけるのかもしれませんが、ぼくにとってはこれだけではほとんど意味不明でした。

各変数の構造体を推定し、これを手作業で正しい C++コードに修正する必要があります。

ここで気になるのはv12で、getArray()の返り値を使っていることから配列であることが予想されます。これは*(v12 + 1)*v12*(v12 + 1) + 4LL * v78という記述からも裏付けられます。

これは C++におけるポインタの挙動を示しており、v12は次のような構造をもっているのではないかと推察されるのです。

struct array {
uint32_t mLength;
uint32_t *ptr;
}

つまり、*v12が配列の長さを*(v12 + 1)v12[0]を表しているというわけです。また、4LL * v78という記述から配列は 4 バイトずつズレています。4 バイトズレるということは 32 ビットなのですから、配列はint型であることがわかるというわけです。

そして、このような構造体は実はキンシャケ探しイベントのアタリ位置を求める際に使われていました。それがまさにsead::PtrArrayImplクラスで、これはスプラトゥーンにおける標準的な配列クラスです。

配列ということはわかったのですが、ここでそれは一度置いておいて、それ以外のコードを見やすくするところからはじめました。

最後のif文は要らなさそうだったので削除し、また途中のv75 >= 0x17は IPSwitch で該当する命令を無効化しても湧き順に全く変化が起きなかったため(おそらくオオモノ数が上限に達した場合にのみ使われる)、同様に削除しました。

ここまでくると割とスッキリしてなんだかわかりそうな気がしてきます。

# 実機での検証

sead::Random rnd;
rnd.init(sead::Randon::getU32());

sead::PtrArratImpl mEnemyArray = Game::Coop::EnemyRareType::getArray();
u32 mLength = mEnemyArray.mLength;
u32 mWeight = 0;
u32 mProb = 0
u32 mTmpId = 0
u32 mRareId = 0

do {
  sead::PtrArrayImpl mRareType = Game::Coop::EnemyRareRype::getArray();
  if (mRareType->mLength <= mProb)
    mTmpId = mRareType[0]
  else
    mTmpId = mRareType[mProb];
  ++mWeight;
  if(!(rnd.getU32() * mWeight >> 0x20))
    mRareId = mTmpId;
  if (mProb <= 6)
    ++mProb;
  else
    mProb = 6
} while (mProb != mLength)

次に先程推定したsead::PtrArrayImplのコードを入れ、見やすくしたものが上になります。

ここで問題となるのは While 文の終了条件で、これはmEnemyArray.mLengthの長さに依存していることがわかります。この値は果たして定数なのか、それとも変数なのかという問題です。

また、同様にmRareTypeも同じことが言えます。わざわざ二度呼び出しているということは呼び出すたびに値が変わるような関数なのでしょうか?

# mTmpId の値を固定する

さて、ここで気になるのは一度仮のオオモノ ID として mTmpId を計算し、それがある条件を満たしたときのみ mRareId に代入して最終的なオオモノ ID として使われているということです。

そこで IPSwitch を使い、この mTmpId を常に mRareType[0[を返すようにしました。こうすることでEnemyRareTypeの配列の 0 番目に何が入っているのかがわかるというわけです。

// Always load mRateType 0 [tkgling]
@disabled
00546718 48818A9A

すると、全てのオオモノがバクダン化しました。よって、配列の先頭はバクダンを示す値が入っていることがわかりました。

そしてぼくは先頭の要素がバクダンになるような配列を既に知っていました。よくイカリングを見ている方ならわかると思うのですが、イカリングにおけるオオモノシャケの並び順なのです。

もしもイカリングにおける配列と、プログラムにおける配列が同じであれば、mRareType = {"Steelhead", "Flyfish", "Scrapper", "Steel Eel", "Tower", "Maws", "Drizzler"}となり、二番目のオオモノはカタパッドになるはずです。

それを検証するため、次は While の継続条件であるmLengthを弄ることにしました。これは結局配列を先頭から見ていき、「バクダン出現の条件を満たすか」「カタパッド出現の条件を満たすか」という風にオオモノ出現チェックを行うアルゴリズムです。

つまり、このチェックは配列の長さである七回実行され、七回あるために全てのオオモノの出現チェックがされるわけです。よって、ここのチェック回数を二回にしてしまえば「バクダンかカタパッド」の出現チェックしか行われないことになり、出現するオオモノがバクダンとカタパッドだけになるはずです。

// Change mLength to 2 [tkgling]
@disabled
005466E4 56008052

そして、このパッチを当てたところ予想通り出現するオオモノがバクダンとカタパッドだけになりました!断定するには早いですが、オオモノの配列はイカリングのものと同じと考えて良さそうです。

# 復元した C++コード

for(u32 mProb = 0; mProb < mRareType.size(); ++mProb) {
  if (mRareType.size() <= mProb)
    mTmpId = mRareType[0];
  else
    mTmpId = mRareType[mProb];
  if (!this.rnd.getU32() * (mProb + 1) >> 0x20)
    mRareId = mTmpId;
}

そして while 文を for 文に変換しておしまいです。

実はここにいたるまでに乱数生成器の初期化に定数をいれてどんなオオモノが出力されるか検証していました。その結果が画像の上の方のやつで 0 と 1 ならテッパン、2 ならタワーという感じです。

問題はこれが正しいかどうかをチェックすることです。要は、コードが合っていたとしても考えがどこかで間違っていたら正しいコードを書いても意味がないからです。オオモノ湧きアルゴリズムが正しいかどうか試すためには、このアルゴリズムに対して適当な初期シードを与え(先程調べた 0 や 1 が望ましい)、実機と同じ予測ができるかを確かめる必要があります。

いちいちコンパイルするのがめんどくさかったので、これを Javascript に移植し、動作テストをしてみることにしました。

Javascript はポインタ・構造体・32 ビット以上のビットシフトが使えないことを除けば基本的には C++と互換性があるのでほとんど同じようにコードを書くことができました。

そして、このコードを実行させたところmInitialSeedを与えることで正しく実機と同じオオモノ予測をすることができました!

# 湧き方向とオオモノ出現

ここまでできれば根幹となる計算アルゴリズムは正しいので、次は各 WAVE の乱数消費回数の文だけループさせてやればいいことになります。

WAVE1 ですとオオモノの湧きは 20 回で、湧き方向変化が 8 回(変化が 8 回ということは 9 回消費されている)なので 20+8+120 + 8 + 1 回乱数が消費されることになります。

Starlight で乱数生成器の中身を覗くとゲーム開始時に一度乱数が消費されている事がわかっていたため、次のような感じで乱数が消費されていき、初期化も合わせて 30 回呼び出されているのではないかと予想できます。

回数 意味
1 初期化?
2 一回目湧き方向計算
3 一匹目オオモノ出現

# Starlight で湧き方向変更回数を調べる

そこで Starlight を使い、乱数生成器の内部状態が変わった回数をカウントするコードを書きました。

すると 29 回乱数が消費されなければいけないにも関わらず何故か 25 回しかカウントされませんでした。これは何故でしょうか?

# 乱数消費はクロックレベルのオーダー

これは当然のことで、Starlight は「常に」乱数生成器の内部状態を見れているわけではありません。チェックする間隔というものが当然存在します。そして、その頻度に比べて乱数消費の間隔があまりにも速すぎるのです。

つまり、本当は二回消費されているにも関わらず、あっという間に二回消費されてしまうためにその変更を検知できず、一回とカウントしてしまうところに問題があったわけです。

しかし、以前の研究では Starlight はオオモノが同時に二体出現した場合でも正しく乱数生成をチェックできていました。速すぎてチェックできない状況というのはどういうケースでしょうか?

それは湧き方向変化とオオモノ出現が同時に行われるケースです。

# 乱数消費表

書くのがめんどくさいので WAVE1 のものだけ載せておきます。詳しく知りたい人はソースコードを読んでください。

内容 秒数
0 初期化
1 湧き方向
2 オオモノ出現(1)
3 オオモノ出現(2)
4 湧き方向 88 秒
5 オオモノ出現(3)
6 オオモノ出現(4)
7 湧き方向 78 秒
8 オオモノ出現(5)
9 オオモノ出現(6)
10 オオモノ出現(7)
11 湧き方向 68 秒
12 オオモノ出現(8)
13 オオモノ出現(9)
14 オオモノ出現(10)
15 湧き方向 58 秒
16 オオモノ出現(11)
17 オオモノ出現(12)
18 オオモノ出現(13)
19 湧き方向 48 秒
20 オオモノ出現(14)
21 オオモノ出現(15)
22 オオモノ出現(16)
23 湧き方向 38 秒
24 オオモノ出現(17)
25 オオモノ出現(18)
26 オオモノ出現(19)
27 湧き方向 28 秒
28 オオモノ出現(20)
29 湧き方向 18 秒
30 湧き方向 8 秒

最初、湧き方向変化は 8 回かと思っていたのですが、乱数は 10 回消費されていることがわかりました。まあひょっとしたら乱数が消費されているだけで湧き方向は変わっていないのかもしれませんが。

# 今後の展望

結局これをやって何がしたかったというと、サーモンランの WAVE として取りうるものの中から、最も難しい(と思われる)WAVE をプレイしたかったんですよね。

いや、本当にそれだけです。Ocean Calc とかぶっちゃけどうでもいいです。

問題は「難しい」をどうやって評価するかなのですが、まあざっくりいえばカタパとコウモリばっかり湧けばかなりきついと思います。通常潮で遠くのタワーとカタパばっかりっていうのもまあ難しそうな気はするのですが、回収が難しいというよりも処理が難しいという方に重点を起きたい感じはします。

@asicssix (opens new window)氏がサーモンランの統計データから各オオモノの金イクラ納品期待値を計算してくれているので、それを使って期待される納品数が最低の WAVE とかを探してみるのも面白いかもしれませんね。

記事は以上。

価格
    えいむーさんは明日も頑張るよ © 2021