0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

初心者が挫折したWii Uゲームの解析、AIと組んだら1日でMODメニューまで作れた話

0
Posted at

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つだけです。

rules.txt
[Definition]
titleIds = 0005000010176900
name = Hoshiyama Mod Menu
path = "Splatoon/Mods/Hoshiyama Mod Menu"
description = Offline mod menu for Splatoon US v288
version = 7
patch_hoshiyama.asm
[HoshiyamaModMenu_v288]
moduleMatches = 0x659C782E   ; 実行ファイル(RPX)のチェックサム。v288 のときだけ当たる

.origin = codecave           ; ここから下は、自由に使えるメモリに置かれる

_HS_Frame:
    stw r3, 0xAD4(r31)       ; 上書きした元の命令を、ここで代わりに実行する
    ; ...自分の処理...
    blr

0x028D0F34 = bl _HS_Frame    ; ゲームのこの命令を、自分の関数への bl に置き換える

基本的な流れは、

  1. ゲームの中で、毎フレーム通る場所の命令を1つ選ぶ
  2. その命令を bl 自分の関数 に置き換える
  3. 自分の関数の中で、上書きした元の命令を代わりに実行してから、やりたい処理をして 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つの仕掛けでできていました。

  1. ステージ名のバッファ([0x101DD330] の先の +0x1C)に、読み込みたいステージの内部名(Fld_Crank00_Vss など)を書く
  2. シーン切り替えの関数(0x028C6434)をシーン7で呼ぶ
  3. 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 のグラフィックパックの仕組み
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?