journal のバナー画像: CAREGIVER × ENGINEER — Blending Human Compassion with Technological Innovation
journal/tags/

未翻訳のUnityゲームに、AIエージェントと日本語化MODを自作した話

日本語対応の無いUnity製カードゲーム『Foretales』に、MelonLoaderとHarmonyでフックする日本語化MODを自作した記録。文字化け・無限翻訳ループ・IL2CPPの罠を、Claude Codeと一緒に一つずつ潰していった。

今回epicgameにて限定で無料配信されていたカードゲーム『Foretales』に日本語対応が無かった。

ゲーム内の説明を見ても、対応言語は英語・仏語・独語・西語・中国語(簡体字/繁体字)まで。日本語は無い。 そこでAIでmodを調べ、試しに導入をしてみるも、バージョンや互換性のせいで思うように動かなかった。 いくら調べても日本語化に成功したという事例もみつからない。

じゃあ作るしかない。

MelonLoaderでゲームにフックを刺し、テキストを横取りして翻訳し、差し戻す。仕組みだけ聞けば単純だ。でも実際にやってみると、文字化け・無限翻訳ループ・IL2CPPの罠と、一筋縄ではいかない問題が次々出てきた。

Claude Codeと二人三脚で潰していった記録を残す。


全体像

やったことはこの5段階だ。

① Dockerで翻訳サーバーを立てる(LibreTranslate)
② MelonLoader + Harmonyでテキストをフックする
③ 文字化け(豆腐□)を直す
④ 意味不明な訳文になるバグを直す
⑤ 翻訳精度を上げるためGoogle翻訳に乗り換える

技術構成はこう。

ゲーム内テキスト(UnityEngine.UI.Text / TMP_Text)
  ↓ Harmonyでsetterをフック
翻訳キャッシュ(cache.json)を確認
  ↓ キャッシュミスなら
翻訳API(最終的にGoogle翻訳の非公式エンドポイント)
  ↓
翻訳結果をメインスレッドで .text に反映

MODはMelonLoader向けのC#(.NET 6)で書いた。土台はでき上がっていたので、今回は「動かない」「文字が壊れる」を一個ずつ潰すフェーズだった。


① Dockerで翻訳サーバーを立てる

最初の翻訳エンジンにはLibreTranslateを選んだ。無料でセルフホストできるからだ。

Docker未導入のマシンだったので、まずWSL2を有効化するところから始めた。

Start-Process wsl.exe -ArgumentList "--install","--no-launch" -Verb RunAs

ここで一度詰まった。wsl --statusを見ても「Virtual Machine Platformを有効にしてください」というエラーが消えない。DISM上は有効化が完了しているのに、反映されていない状態だった。

再起動したら直った。

Windowsの機能有効化系はだいたい再起動が必要、という基本を忘れかけていた。

再起動後は一直線だった。

winget install -e --id Docker.DockerDesktop
docker run -d --name libretranslate -p 5000:5000 libretranslate/libretranslate

Docker Desktopを一度手動起動(初回同意画面はGUI操作なので自動化できない)してから、docker psでLibreTranslateコンテナが立ち上がっていることを確認。

curl http://127.0.0.1:5000/translate -X POST -H "Content-Type: application/json" \
  -d '{"q":"Hello","source":"en","target":"ja","format":"text"}'

{"translatedText":"お問い合わせ"} が返ってきた。"Hello"が「お問い合わせ」になるのは翻訳精度の問題(後述)だが、疎通は成功。ここまでは順調だった。


② 文字化け(豆腐□)を直す

MODをビルドしてゲームを起動すると、翻訳自体は動いていた。ログにも翻訳結果が出ている。

なのに画面は全部これだった。

Foretales□□□□□!

日本語の部分が全部、豆腐(□)になる。

ゲーム画面の会話ウィンドウで、日本語部分が全て豆腐(□)の羅列になって表示されている

原因はすぐには分からなかった。翻訳結果をよく見ると、cache.jsonにはちゃんと"グラフィック"のような正しい日本語がUnicodeエスケープで保存されている。翻訳自体は正常。

つまりデータではなくレンダリングの問題だった。

このゲームは元々、英・仏・独・西・中国語(簡体字/繁体字)にしか対応していない。中国語対応があるので漢字グリフはあるが、ひらがな・カタカナのグリフがフォントに無い。だから翻訳結果をそのまま表示すると、存在しないグリフの代わりに豆腐が出る。

対策は、TextMeshProのグローバルフォールバックフォントに、日本語グリフを含むフォントアセットを追加登録すること。

var bundle = AssetBundle.LoadFromFile(bundlePath);
var rawObjects = bundle.LoadAllAssets(); // 非ジェネリック版
foreach (var obj in rawObjects)
{
    var font = obj.TryCast<TMP_FontAsset>();
    if (font == null) continue;
    TMP_Settings.fallbackFontAssets.Add(font);
}

使ったフォントアセットバンドルは、XUnity.AutoTranslatorというMOD向け翻訳フレームワークが配布している「Arial Unicode MS SDF」のAssetBundle。ゲームのUnityバージョン(2020.3.31f1)より新しく作られたバンドルは読み込めないリスクがあるため、ゲームのバージョンと同じか古いバージョンでビルドされたものを選んだ。

これでようやく、ひらがな・カタカナが文字として表示されるようになった。

ハマったポイント:ジェネリックメソッドが実行時に落ちる

最初、素直にLoadAllAssets<TMP_FontAsset>()(ジェネリック版)を使ったら、こう落ちた。

System.NotSupportedException: Method unstripping failed
   at UnityEngine.AssetBundle.ConvertObjects[T](Il2CppReferenceArray`1 rawObjects)
   at UnityEngine.AssetBundle.LoadAllAssets[T]()

IL2CPPは「ゲーム本体が実際に使っている型引数の組み合わせ」でしかジェネリックメソッドをコンパイルしない。今回のゲームはLoadAllAssets<TMP_FontAsset>()という組み合わせを一度も呼んでいないので、その実体がバイナリに存在せず、呼び出しようがない。

回避策は、非ジェネリック版LoadAllAssets()UnityEngine.Objectの配列を取り、TryCast<T>()で型を絞り込むこと。TryCastはIL2CPPのバイナリを呼ぶのではなく、interopライブラリ側の完全マネージドコードで実装されているので、この制約を受けない。

IL2CPP MODを書くなら覚えておいて損はないパターンだと思う。


③ 意味不明な訳文になるバグ

文字は表示されるようになった。でも訳文の中身がおかしい。

最後の からから からから からから からから からから からから からから!

「から」の壁。他にも「彼はでで.」のような、原形をとどめていない文がいくつも出てきた。

ゲーム画面の会話ウィンドウで、訳文が「からから」の連続になって意味を成していない

cache.jsonを漁って原因が分かった。

"One last job, and our days of stealing apples from the market are behind us!":
  "最後の仕事と、市場からリンゴを盗む私たちの日が私たちの背後にある!",
 
"最後の仕事と、市場からリンゴを盗む私たちの日が私たちの背後にある!":
  "最後の からから からから ... からから!"

一度翻訳した日本語が、もう一度「未翻訳の原文」として扱われ、翻訳にかけ直されていた。

日本語を「英語のつもり」で翻訳エンジンに投げれば、当然おかしな結果になる。しかもその壊れた結果がそのままキャッシュに永続化されるので、直らないまま蓄積されていく。

原因はMODの二重翻訳防止ロジックにあった。

// 修正前
if (Cache.TryGetValue(original, out var cached))
{
    value = cached;
    return; // KnownTranslatedValues への登録を忘れている
}

「自分が出力した訳文を、もう一度原文として拾わないためのガード」が、キャッシュヒット時のパスにだけ抜けていた。非同期翻訳が完了した直後のパスにはちゃんとガードが入っていたので、初回だけ動いて2回目以降のセッションで壊れる、という非対称なバグになっていた。

// 修正後
if (Cache.TryGetValue(original, out var cached))
{
    value = cached;
    KnownTranslatedValues[cached] = 0; // これを追加
    return;
}

TextMeshProのレイアウト再計算などでtext.text = text.textのような自己再設定が起きるたびに、この抜け穴を突かれていたと思われる。

1行足りないだけで、遊べないレベルの訳文崩壊が起きる。地味に一番怖いバグだった。


④ 翻訳精度を上げるためGoogle翻訳に乗り換える

バグを直しても、LibreTranslateの訳文は「ぎりぎり読めるレベル」だった。

DeepLへの乗り換えも検討したが、API利用にはアカウント登録とAPIキー発行が必要。今すぐ試したかったので、キー不要で使えるGoogle翻訳の非公式Webエンドポイントに切り替えた。

var url = $"https://translate.googleapis.com/translate_a/single" +
          $"?client=gtx&sl={source}&tl={target}&dt=t&q={Uri.EscapeDataString(text)}";

これはtranslate.google.comの画面自体が使っているエンドポイントで、APIキーもGoogleアカウントも不要。ただし非公式なので、Google側の都合で予告なくブロック・レート制限される可能性はある。無料と引き換えのリスクとして割り切った。

同じ文章で比較するとこうなる。

LibreTranslate: 最後の仕事と、市場からリンゴを盗む私たちの日が私たちの背後にある!
Google翻訳    : 最後の仕事が終わり、市場からリンゴを盗む日々は終わりました。

一目瞭然だった。


⑤ 細かい表示崩れを直す

翻訳精度は劇的に上がった。ただ、まだ2つ気になる崩れが残っていた。

1つ目:ゲーム内アイコンのタグがそのまま文字として表示される。

5<sprite="card_effects_hints"index=21tint=1>これだけあれば数日は大丈夫ですよ

ゲーム画面のカード説明文に、本来はアイコンに変換されるべきspriteタグの生の文字列が表示されている

TextMeshProのリッチテキストタグ(<sprite=...>のようなアイコン指定)を、地の文と一緒に翻訳エンジンへ投げていたのが原因だ。翻訳エンジンはHTMLタグの意味を知らないので、属性名を勝手に書き換えてしまう(card_effects_hintscard effects hintsのようにアンダースコアが空白になる、など)。ゲーム側は元の文字列と完全一致でアイコンを探すので、書き換えられた時点でアイコンが表示されなくなる。

対策は「翻訳前にタグをプレースホルダーに退避し、翻訳後に元に戻す」という、この手のツールでは定番の手法。

// <sprite=...> のようなタグを一時的にプレースホルダーへ
var protectedText = RichTextTagProtector.Protect(original, out var tags);
var translated = await TranslationClient.TranslateAsync(protectedText);
translated = RichTextTagProtector.Restore(translated, tags);

2つ目3〜6のような範囲表現の「〜」(全角チルダ)が豆腐になる。これはフォントのグリフが単純に無いだけだったので、ASCIIの~に置換する処理を足して解決した。「!」「?」も同様に豆腐になっていたので、あわせて対応した。

これで一見解決……したはずだった。

プレースホルダー自体が化けるという想定外

最初、プレースホルダーにはzqxk0zqxkのような英数字トークンを使っていた。これがまた新しいバグを生んだ。

3zqxk4zqxk 厳しい

ゲーム画面のインタラクション表示で、数字とプレースホルダー文字列「3zqxk4zqxk」が密着した状態のまま壊れて表示されている

アイコンと数字がスペース無しで密着するステータス表示(アイコン+アイコン3アイコンのような表記)で、翻訳エンジンがプレースホルダーを「意味のある単語」と誤認し、勝手に別の文字列へ「補正」してしまっていた。プレースホルダーで守ったはずのタグが、プレースホルダー自身の見た目のせいで壊れるという、笑えないオチ。

最終的な対策は、プレースホルダーをUnicodeの私用領域(Private Use Area, U+E000〜)の1文字に変更すること。

private static string Placeholder(int index) =>
    char.ConvertFromUtf32(0xE000 + index);

私用領域の文字は、どの言語の翻訳モデルの学習データにも一切登場しない。だから「補正しよう」とする余地そのものが無くなる。

デプロイ前に、危険なパターン(数字への直接隣接、プレースホルダー同士の直接隣接、実際のゲームの表示に近い密着パターン)を実際にGoogle翻訳へ通して検証してから本番投入した。全パターンでプレースホルダーは無傷で生還した。

これでようやく、豆腐も、意味不明な訳文も、タグの化けも無くなった。


詰まりポイントまとめ

問題原因対策
WSL2が有効化されないDISM上は完了しているが未反映再起動する
日本語が豆腐(□)になるゲームのフォントにひらがな・カタカナのグリフが無いTMPのグローバルフォールバックフォントに追加登録
LoadAllAssets<T>()が実行時エラーIL2CPPは未使用のジェネリック実体を呼べない非ジェネリック版 + TryCast<T>()
訳文が意味不明になる訳文が原文として再翻訳されるループキャッシュヒット時にも「既に訳文である」ことを記録する
<sprite=...>タグが壊れるタグごと翻訳エンジンに投げていたプレースホルダーに退避してから翻訳
プレースホルダー自体が化ける英数字トークンが「単語」と誤認されるPrivate Use Areaの1文字に変更

OSSとして公開した

一通り動くようになったので、GitHubに公開した。

判断に迷ったのは「ゲーム名を出していいのか」という点だった。結論としては、翻訳MODのようなファン制作物で対象ゲーム名を説明目的で挙げること自体は、modコミュニティでは一般的な慣行らしい(いわゆるnominative fair use)。ただし、

という形に落ち着いた。ソースコード側も、MelonGame属性の1行を書き換えれば別のゲームにも流用できる構成になっているので、「Foretales専用ツール」ではなく「MelonLoader向け翻訳MODフレームワーク」として公開している。


まとめ

今回一番学んだのは、「動く」と「正しく動く」の間には無数の落とし穴があるということだ。

翻訳は動いた。でも文字が表示されない。表示されても意味不明。直しても細部が崩れる。崩れを直したら、直すための仕組み自体が新しい崩れを生む。

一つずつ、実機のログとスクリーンショットを見ながら潰していく。地味な作業だけど、確実に前に進む。

好きなゲームを日本語で遊びたい、という個人的な動機から始まった話だったが、結果的にIL2CPP MODを書く上で汎用的に使える知見がいくつも手に入った。それがこの記事とリポジトリを公開した理由でもある。