Contents
WAVE内容とは
サーモンランでは少なくとも以下の九つの内容がバイト開始時に決定されています。
- 各WAVEの潮位
- 各WAVEのイベント内容
- 各WAVEのシャケの湧き方向
- 各WAVEの出現するオオモノシャケの種類
- キンシャケ探しイベントでのアタリ位置
- 霧イベントでのキンシャケのドロップ数
- ラッシュイベントでの最初にヒカリバエがつくプレイヤー
- ランダム時に支給されるブキ
- 支給されるスペシャルウェポン
これらの内容をWAVE中のプレイヤーの行動などで変化させることは絶対にできません。
サーモンラン通信プロトコル
サーモンランはバイト開始時にCnet::PacketSeqEventCoopSetting::PacketSeqEventCoopSetting()
という関数が呼び出され、ホストが接続しているクライアントに対して設定されたパラメータを送信します。
送信される内容は以下の通り。
- 初期シード(サーモンランのゲームの全てを司る値)
- インクの色(イカちゃんチームの色のみ変更可能)
- BGMの種類(通常用とランダム用があるみたい)
- 遊ぶステージ(ナワバリのステージなどを選ぶとクラッシュする)
ここで大事になるのが初期シードであり、これが先程述べた九つのWAVE内容全てを決定する値になります。
各パラメータの計算アルゴリズム
アルゴリズム自体はC++, Python, Javascriptなどに移植しているのですが、今回は最もわかりやすいと思われるPythonのコードを紹介します。
初期シードから擬似乱数生成
class NSRandom: mSeed1 = 0x00000000 mSeed2 = 0x00000000 mSeed3 = 0x00000000 mSeed4 = 0x00000000 def __init__(self): pass def init(self, seed): self.mSeed1 = 0xFFFFFFFF & (0x6C078965 * (seed ^ (seed >> 30)) + 1) self.mSeed2 = 0xFFFFFFFF & (0x6C078965 * (self.mSeed1 ^ (self.mSeed1 >> 30)) + 2) self.mSeed3 = 0xFFFFFFFF & (0x6C078965 * (self.mSeed2 ^ (self.mSeed2 >> 30)) + 3) self.mSeed4 = 0xFFFFFFFF & (0x6C078965 * (self.mSeed3 ^ (self.mSeed3 >> 30)) + 4) def getU32(self): n = self.mSeed1 ^ (0xFFFFFFFF & self.mSeed1 << 11) self.mSeed1 = self.mSeed2 self.mSeed2 = self.mSeed3 self.mSeed3 = self.mSeed4 self.mSeed4 = (n ^ (n >> 8) ^ self.mSeed4 ^ (self.mSeed4 >> 19)) return self.mSeed4
乱数生成器は初期シードで初期化され、その後getU32()
を呼び出すことで乱数を生成します。
ここで大事なことは、初期シードさえわかればその後生成される全ての乱数は予測可能だということです。
潮位・イベント決定アルゴリズム
def getWaveInfo(self): mEventProb = [18, 1, 1, 1, 1, 1, 1] mTideProb = [1, 3, 1] self.rnd.init(self.mGameSeed) for wave in range(3): sum = 0 for event in range(7): if ( (wave > 0) and (self.mEvent[wave - 1] != 0) and (self.mEvent[wave - 1] == event) ): continue sum += mEventProb[event] if (self.rnd.getU32() * sum >> 0x20) < mEventProb[event]: self.mEvent[wave] = event sum = 0 for tide in range(3): if tide == 0 and 1 <= self.mEvent[wave] and self.mEvent[wave] <= 3: continue sum += mTideProb[tide] if (self.rnd.getU32() * sum >> 0x20) < mTideProb[tide]: self.mTide[wave] = 0 if self.mEvent[wave] == 6 else tide
アルゴリズムではまず最初にイベントを決定します。
WAVE1のイベントは完全にランダムに選ばれますが、WAVE2以降は「一つ前のWAVEと同じイベントではない」「一つ前のWAVEはイベントなしではない」という条件が付きます。
なので、連続して同じイベントが発生することは絶対にありません。
イベントが決まったあとに潮位を決定しますが、初期状態は通常潮位に設定されています。
ここで潮位を計算するのですが、潮位が干潮になった場合「イベントがキンシャケ探しかグリルかラッシュ」ならその計算をなかったことにします。そして、ドスコイ大量発生の場合はどんな潮位であっても強制的に干潮に変化します。
まあこれはコードを読んだ方がわかりやすいですね。
各WAVEシード生成アルゴリズム
サーモンランには最も基本となる初期シードの他に、WAVEごとの細かいパラメータを決定するWAVEシードがあります。WAVEは三つ存在するので、WAVEシードは三つあるわけです。
そして、大事なことは全てのWAVEシードは初期シードから生成されるということです。
なので、初期シードが決まった時点でWAVEシードも予測可能になります。
def setWaveMgr(self): self.rnd.init(self.mGameSeed) self.rnd.getU32() self.mWaveMgr = [ WaveMgr(0, self.mGameSeed), WaveMgr(1, self.rnd.getU32()), WaveMgr(2, self.rnd.getU32()), ]
興味深いのはWAVE1のWAVEシードは初期シードであるということです。
そしてWAVE2は初期シードから二回目に生成された乱数が使われます。何故一回、乱数をむだうちしているのかはわかりません。
キンシャケ探しアタリ位置計算アルゴリズム
def getGeyserPos(self): self.rnd.init(self.mWaveSeed) mReuse = [False, False, False, False] mPos = ["D", "E", "F", "G"] mSucc = [] for idx in range(15): for sel in range(len(mPos) - 1, 0, -1): index = (self.rnd.getU32() * (sel + 1)) >> 0x20 mPos[sel], mPos[index] = mPos[index], mPos[sel] mReuse[sel], mReuse[index] = mReuse[index], mReuse[sel] mSucc += mPos[0] if mReuse[0]: self.rnd.getU32() return mSucc
キンシャケ探しのアタリ位置を計算するためには「キンシャケ探しのアタリ位置候補」と「乱数消費フラグ」の二つが必要になります。
今回は朽ちた方舟ポラリスの満潮時のアタリ位置を計算するコードをご紹介します。
乱数消費フラグがなんのためにあるかと言うと、アタリ位置に対してゴール候補が二箇所以上ある場合はどちらのゴールに向かうかを計算するために一回余計に乱数が消費されるためです。
満潮ポラリスは常にゴール候補が一つしかないので、全てのアタリ位置に対して乱数消費フラグはFalseになっています。
湧き方向計算アルゴリズム
def getEnemyAppearId(self, previousId): mArray = [1, 2, 3] mIndex = 0 w6 = 3 x6 = 3 v5 = previousId w7 = mArray if not (id & 0x80000000): w8 = w6 - 1 while True: v17 = w8 w9 = w7[mIndex] if w9 < id: break w6 -= w9 == id if w9 == id: break w8 = v17 - 1 mIndex += 1 if not v17: break mIndex = 0 x7 = mArray x8 = 0xFFFFFFFF & (self.rnd.getU32() * w6 >> 0x20) while True: x9 = x7[mIndex] x10 = 0 if x8 == 0 else x8 - 1 x11 = x9 if x8 == 0 else v5 x12 = 5 if x9 == v5 else x8 == 0 if x9 != v5: x8 = 0xFFFFFFFF & x10 id = x11 if (x12 & 7) != 5 and (x12 & 7): break x6 -= 1 mIndex += 1 if not x6: return v5 return id
Pythonではポインタが使えないため、アセンブラから上手く復元することができませんでした。
また、これらのコードは最適化できていないため読んでも意味のわからないものになっています。
ちなみに、previousIdは一つ前の湧き方向を意味します。何故かはわからないのですが、previousIdが1だと、この関数は殆どの場合(絶対かもしれない)1以外を返します。
出現オオモノ計算アルゴリズム
def getEnemyId(self): mRnd = NSRandom.NSRandom() mRnd.init(self.rnd.getU32()) mRareId = 0 for mProb in range(7): if not (mRnd.getU32() * (mProb + 1) >> 0x20): mRareId = mProb return mRareId
出現するオオモノは湧き方向に比べて簡単です。
オオモノが出現することが呼び出されるたびに新たに乱数生成器を乱数で初期化し、生成した乱数から計算します。計算方法も単純で、7で割ったあまりによって出現するオオモノが決まるだけです。
未解決アルゴリズム
- 霧イベントでのキンシャケのドロップ数
- ラッシュイベントでの最初にヒカリバエがつくプレイヤー
- ランダム時に支給されるブキ
- 支給されるスペシャルウェポン
この四つに関しては、未だにアルゴリズムが解析できていないため初期シードから予測することができません。
霧イベントについては、どの関数がドロップ数を決めているかまでは分かっているのですが「どの乱数生成器が使われているか」がわかっていないため、予測することができていません。
まあこれが一番解析しやすそうな気はするので、誰か頼んだ。
Ocean Calc
で、今まで紹介した全アルゴリズムを搭載したWAVE内容予測アプリがこのOcean Calcです。
計算アルゴリズムはオンラインプレイでもLanPlayでも同じなので、一度遊んだシードを特定することができれば、それ以後の全ての湧き方向やイベント内容を先読みすることができます。
例えば、ポラリス満潮キンシャケ探しの現在の世界記録である122納品を達成したときのシードは0xFABAD087
であることがわかっています。

上のリンクで実際にどんなWAVE内容なのかがチェックできるので、ズレていないことを確かめてみてください。
これを利用すればキンシャケ探しで一発でアタリ位置を見つけることが可能ですし、稼げないWAVEだということが始める前からわかるわけです。
SeedHackとは
ぼくが勝手につくった言葉で、ホストが送信するシードをパッチを使って強制的に変更することで任意のWAVEを呼び出すことができるハックのことです。
イカッチャにおいてもやりたいイベントの組み合わせのWAVEを引くのはとてつもなく低い確率になるので、初期シードを好きなものにすることで確実に毎回同じWAVEが来るようにするわけです。
SeedHack自体は初期シードを変更しているだけですので、言ってしまえば乱数調整と同じでそれ自体に納品数を増やしたりパラメータを強化したりする効果は全くありません。好きなWAVEを呼び寄せることができると言うだけです。
で、その呼び寄せたいWAVEはキンシャケ探しのアタリ位置なども事前に計算しているので、一回も外すことなくアタリを当てられるというだけです。
自分がアップロードしている多くのLanPlayの動画はこのハックを使って理想のWAVEを呼び寄せています。でないと「いいWAVE」がくるのを待って水没を繰り返すのが時間の無駄だからです。
結論から言えば、SeedHack自体はパッチを使用してはいるものの完全なチートとは言えません。時間をかければ誰でも同じ状況が再現できます。
ちなみに404納品を達成したシードは以下のリンクから見れます。

記事は以上、勝ったなガハハ。
自身を天才と信じて疑わないマッドサイエンティスト。二つ上の姉は大英図書館特殊工作部勤務、額の十字架の疵は彼女につけられた。
コメント