Wii U版スプラトゥーンのMODメニューを、Claude Code+Ghidraの静的解析と実機テストで作った話
はじめに
Wii U版『スプラトゥーン』(以下 Splatoon 1)を Cemu で遊ぶための、オフライン専用の MOD メニュー「Hoshiyama Mod Menu」を作りました。
できることはこんな感じです。
- Fly:自由に飛べる。飛んでいる間は撃てて、無敵で、壁や敵陣のバリアもすり抜けられる
- シーン移動:広場・ロビー・射撃場・タコツボバレー・バトルドージョーに直接移動
- Bot Battle:好きなマップで 4対4 のオフライン対戦(相手は bot)
- 敵 bot の呼び寄せ:スポーン地点から動かない bot を、自分の前に並べる
- bot の武器をランダムに、bot の名前を好きな名前に
もともと僕はリバースエンジニアリングの経験がほとんどなく、7月に Ghidra で同じことをやろうとして挫折しています。今回は、
- アセンブリを書くのは Claude(チャット)
- 実行ファイルの静的解析は Claude Code + Ghidra(ヘッドレス)
- Cemu での実機テストと、「次に何をやるか」を決めるのは自分
という分担で進めたところ、ほぼ1日で形になりました。この記事では、その作り方と、ハマったところをまとめます。
この記事の内容は、自分で購入したソフトを自分の Wii U から吸い出して、オフラインで遊ぶことを前提にしています。ゲームのファイルの入手方法や、本体の改造手順は扱いません。
また、無敵やすり抜けをオンライン(Pretendo Network など)で使うと、ほかのプレイヤーの迷惑になります。オンラインに接続するときは、パックを必ず無効にしてください。
環境
| 項目 | 内容 |
|---|---|
| OS | Windows 11 |
| エミュレータ | Cemu |
| ゲーム | Splatoon US版(0005000010176900)、更新データ v288 |
| 解析ツール | Ghidra 12.1.2 + GhidraRPXLoader v0.9.2、JDK 21 |
| AI | Claude(チャット)、Claude Code(Pro プラン) |
Cemu のグラフィックパックで MOD を作る仕組み
Cemu には「グラフィックパック」という仕組みがあり、名前のとおり画質の改善にも使われますが、ゲームの実行ファイルの命令を書き換えることもできます。フォルダの中身は2つだけです。
[Definition]
titleIds = 0005000010176900
name = Hoshiyama Mod Menu
path = "Splatoon/Mods/Hoshiyama Mod Menu"
description = Offline mod menu for Splatoon US v288
version = 7
[HoshiyamaModMenu_v288]
moduleMatches = 0x659C782E ; 実行ファイル(RPX)のチェックサム。v288 のときだけ当たる
.origin = codecave ; ここから下は、自由に使えるメモリに置かれる
_HS_Frame:
stw r3, 0xAD4(r31) ; 上書きした元の命令を、ここで代わりに実行する
; ...自分の処理...
blr
0x028D0F34 = bl _HS_Frame ; ゲームのこの命令を、自分の関数への bl に置き換える
基本的な流れは、
- ゲームの中で、毎フレーム通る場所の命令を1つ選ぶ
- その命令を
bl 自分の関数に置き換える - 自分の関数の中で、上書きした元の命令を代わりに実行してから、やりたい処理をして
blrで戻る
です。CPU は PowerPC(Espresso)なので、アセンブリも PowerPC で書きます。
フックする場所が関数の途中のときは、ゲームがその後で使うレジスタを壊さないことが一番大事です。今回は、関数の途中のフックでは r12 と cr0 しか触らないようにしたり、ゲームの関数を呼ぶ処理は「関数の入口」のフック(描画関数の入口など)にまとめたりしました。
既存の MOD を読んで、入口を見つける
いきなり実行ファイルを読んでも、Splatoon 1 にはシンボル(関数名)が残っていないので、どこから手をつければいいかわかりません。7月の挫折はまさにこれでした。
そこで今回は、GameBanana で公開されている OneShotted Safe(作者: Folp さん、MIT ライセンス)を読むところから始めました。これもグラフィックパックなので、中身がテキストのアセンブリで全部読めます。
Freecam(カメラを自由に飛ばす機能)のコードを読むと、こんなことがわかりました。
lis r12, 0x101E
lwz r12, 0x16E8(r12) ; [0x101E16E8] = 自分のプレイヤー
lwz r9, 0x248(r12) ; X座標
lfs f0, 0x24C(r12) ; Y座標
lwz r9, 0x250(r12) ; Z座標
-
[0x101E16E8]が自分のプレイヤーのオブジェクト -
+0x248 / +0x24C / +0x250が座標 -
[+0x8D0]の先の+0x1B8 / +0x1C0が向き(cos / sin) -
0x028D0F34が、コントローラの入力を読んだ直後の場所(r31が入力バッファ)
7月に Cheat Engine で何時間も探していた座標が、最初から書いてあったわけです。
Fly を作る
Freecam は「カメラ」を動かしていただけなので、足す先をプレイヤーの座標に変えれば飛べるはず、というのが出発点です。
スティックの入力を、向いている方向に合わせる
左スティックの入力 (x, y) を、プレイヤーの向き (c, s) で回転させて、ワールド座標での移動量にします。
lfs f1, 0x20(r31) ; 左スティック X
lfs f2, 0x24(r31) ; 左スティック Y
lfs f4, 0x1B8(r8) ; 向き c
lfs f5, 0x1C0(r8) ; 向き s
fmuls f7, f2, f4
fmuls f8, f1, f5
fsubs f7, f7, f8 ; dX = y*c - x*s
fmuls f8, f1, f4
fmuls f9, f2, f5
fadds f8, f8, f9 ; dZ = x*c + y*s
lfs f0, 0x248(r9)
fadds f0, f0, f7
stfs f0, 0x248(r9) ; X += dX
右スティックの上下は、そのまま Y(高さ)に足しています。
最初の版:計算ごと止める(Freeze)
OneShotted には「Freeze Player」という機能があり、player+0x4E4 に 0x0FFFFFFF を書くとプレイヤーの計算が止まります。これを使うと重力も止まるので、座標を書き換えるだけで飛べました。
ただし、射撃も止まるので撃てません。
改良版:縦の速度だけ打ち消す(Hover)
Claude Code に解析してもらったところ、縦の速度は player+0x304 にあり、毎フレームこう更新されていることがわかりました。
速度 = 速度 × 0.98 (空気抵抗)
速度 = 速度 − 0.08 (重力)
なので、毎フレームの最初に
$$
v \times 0.98 - 0.08 = 0 \quad\Rightarrow\quad v = \frac{0.08}{0.98} \approx 0.0816
$$
を書いておけば、空気抵抗と重力のあとでちょうど 0 になり、その場に浮き続けます。計算を止めていないので、飛びながら撃てるようになりました。
無敵とすり抜け
Hover は計算を止めていないので、壁にはぶつかるし、やられることもあります。そこで、
-
自分がやられる関数(
0x02644A54)の入口をフックして、「自分かつ Fly 中」ならすぐ戻る - 物理の更新の中で、地形の当たり判定と押し出し2つを呼ぶかどうか決めている比較(3か所)をフックして、「自分かつ Fly 中」なら呼ばない側に倒す
ようにしました。関数の入口のフックは、まだスタックを作っていないので、何もせず blr するだけで「やられなかったこと」にできます。
_HS_NoDie: ; r3 = プレイヤー
; (Fly が OFF なら _HSND_die へ)
lis r12, 0x101E
lwz r12, 0x16E8(r12) ; 自分のプレイヤー
cmpw r3, r12
bne _HSND_die
blr ; やられずに戻る
_HSND_die:
mflr r0 ; 上書きした元の命令
b 0x02644A58 ; 関数の続きへ
これで、撃てて、無敵で、壁も敵陣のバリアもすり抜けられる Fly になりました。
Claude Code に静的解析を回してもらう
OneShotted からわかることには限りがあるので、ここからは実行ファイルそのものを読む必要があります。
解析環境
最初は、AI エージェントにリバースエンジニアリングのツールをつなぐ REA を使うつもりでした。ただ、REA の Windows 版の Ghidra 対応は x86 / ARM だけで、Wii U の PowerPC には対応していませんでした。
そこで、7月に用意していた Ghidra + GhidraRPXLoader を、Claude Code からヘッドレス(analyzeHeadless.bat)で直接動かしてもらうことにしました。Claude Code に頼むと、こんなラッパースクリプトを作ってくれました。
.\ghidra.ps1 decomp out.c 0x026BA4D8 # 関数を逆コンパイル
.\ghidra.ps1 xrefs out.txt 0x101E16E8 # そのアドレスを参照している場所の一覧
.\ghidra.ps1 strings out.txt PlayerMgr # 文字列の検索(参照元つき)
.\ghidra.ps1 disasm out.txt 0x021A743C # 関数の命令を全部書き出す
GhidraRPXLoader を使うので、RPX をそのまま読めて、Espresso の命令も正しく解釈されます。
notes.md を「共有の記憶」にする
一番効いたのは、わかったことをすべて notes.md に書き足していく運用です。
- Claude Code には「notes.md を読んで、『まだわかっていないこと』の1番を調べて、結果を追記して」と頼む
- 結果を見て、チャットの Claude と一緒にパッチを書く
- Cemu で試して、結果(動いた/動かなかった)を notes.md に書く
これを繰り返しました。会話を /clear しても notes.md を読めば続きから始められるので、Pro プランの使用量でも十分に回せました。お風呂に入っている間に、Claude Code に4つの調査をまとめて頼んでおく、ということもしています(auto モードで動かしました)。
わかったこと(一部)
8人分のプレイヤーの一覧(PlayerMgr)
mgr = [[0x101E2814] + 0x268]
player = getPlayer(mgr, 番号) ; 0x026BA4D8
[mgr + 0x2B8 + 番号*4] == -1 なら、人が操作していない(= bot)
player + 0x2C = チーム
bot の情報を作る関数(0x021A728C)
-
+0x06に名前(UTF-16、最大16文字)。bot は"Player%d" -
+0x40〜+0x4Cに武器セット・メイン・サブ・スペシャル - 名前を書いた後、武器を決める前に
0x021A740Cを通る
ここをフックして、名前と武器を書き換えています。
Bot Battle と bot の呼び寄せ
オフライン対戦の始め方
OneShotted の Bot Battle を読むと、3つの仕掛けでできていました。
- ステージ名のバッファ(
[0x101DD330]の先の+0x1C)に、読み込みたいステージの内部名(Fld_Crank00_Vssなど)を書く - シーン切り替えの関数(
0x028C6434)をシーン7で呼ぶ -
0x021A7D84をli r31, 8にして、人数を8人にする
自分の版では、これに加えて射撃場(Fld_ShootingRange_Shr)とチュートリアル(Fld_Tutorial00_Ttr)でも対戦できるようにしました。
敵の bot を目の前に呼ぶ
bot はスポーン地点から一歩も動かないので、敵陣のバリアに守られて倒せません。そこで、PlayerMgr で8人を順番に見て、
- bot(
[mgr+0x2B8+i*4] == -1)で - 自分とチーム(
+0x2C)が違う
プレイヤーだけ、自分の正面に並べるようにしました。
; 前ベクトル (c, s)、右ベクトル (-s, c) として
; dX = 前*c - 左右*s、dZ = 前*s + 左右*c
fmuls f6, f2, f4
fmuls f7, f3, f5
fsubs f6, f6, f7 ; dX
fmuls f7, f2, f5
fmuls f8, f3, f4
fadds f7, f7, f8 ; dZ
bot の武器と名前
武器は、ゲーム自身の乱数関数(0x028FC0DC)で番号を選び直しています。mulhwu で「乱数 × 個数」の上位32bitを取ると、0〜個数-1 にきれいに収まります。
bl 0x028FC0DC ; r3 = 乱数 32bit
mulhwu r4, r3, r0 ; r4 = 0〜個数-1(r0 = 武器セットの個数)
名前は、好きなラッパーの名前を8人分用意して、かぶらないように選んでいます。
ハマったところ
+0x4C はチームではなかった
OneShotted には「bot のチーム分け」のつもりのパッチ(0x021A743C で +0x4C に書く)がありましたが、Claude Code の解析で +0x4C はスペシャルの ID だとわかりました。チームはもともとゲームが +0x30 に入れていて、このパッチは bot のスペシャルを上書きしていただけでした。外したら、スペシャルが武器に合ったものになりました。
関数の入口を bl だと思い込んでいた
「0x021A728C を呼んでいるのは 0x021A7BC8 だけ」というメモを見て、0x021A7BC8 を bl 自分の関数 に置き換える版を書いてしまいました。実機で試す前に Claude Code に確認してもらったところ、0x021A7BC8 は関数の入口で、bl ではありませんでした。そのまま入れていたら、ゲームが落ちていたはずです。
パッチを当てる前に、その場所の命令を逆アセンブルして確かめるのは大事ですね。
bot がイカのままで立たない
呼び寄せた bot はずっとインクに潜った姿でした。「イカになりたいフラグ」(+0x584)を毎フレーム 0 にしても変わりません。
デバッグ用に、bot の状態をメニューに表示して調べたところ、原因はプレイヤーの大きな状態 +0x7B4 が 5(ふつうに動ける状態)になっていなかったことでした。試合開始の演出が bot の分だけ終わっておらず、そのせいでイカの姿に固定されていたようです。呼び寄せるときに +0x7B4 = 5 を書いたら、ちゃんと立ち上がりました。
ステージ名の一覧から、広場(Fld_Plaza00_Plz)・ロビー(Fld_MatchRoom_Mch)・タコツボバレー(Fld_World00_Wld)も試しましたが、どれもフリーズしました。専用のシーンで動く前提のステージは、対戦のシーンでは読み込めないようです。
ちなみに、ステージの内部名の最後の3文字で種類がわかります。
| 印 | 種類 |
|---|---|
_Vss |
対戦 |
_Msn |
ヒーローモード(Fld_Boss〇〇King_Bos_Msn はボス戦) |
_DRC |
GamePad の画面用 |
_Shr |
射撃場 |
_Plz / _Mch / _Wld
|
広場 / ロビー / タコツボバレー |
_Ttr / _Stf
|
チュートリアル / スタッフロール |
次は、bot に偽の入力を渡してbot を動かすのに挑戦したいと思っています。
クレジット
- OneShotted Safe by Folp(MIT License)。プレイヤーの座標・入力・描画・シーン切り替えのアドレスは、このMODの解析結果を出発点にしています
- GhidraRPXLoader by Maschell
- Cemu と Cemu のグラフィックパックの仕組み