汎用プレイブック + 解析引き継ぎ v11 / Shiver Games ×3・Kaos Studios ×1・High Moon Studios ×1・Croteam ×1・Space Bullet ×1・Keen Software House ×1
8作やって、エンジンもテキスト格納方式も毎回別物だった (独自バイナリ/Unityシリアライズオブジェクト/平文CSV動的読み込み/UE3パッケージ/難読化コンテナ/ 独自エンジンのZIP偽装コンテナ/データを触らない実行時フック/ 公式データが揃っているのにコードで封印されている)。 この経験からタイトルが変わっても効く部分と毎回作り直しになる部分を切り分け、 前半をエンジン非依存のプレイブックとして独立させた。次にどのゲームを日本語化するときも、 シリーズや作者に関わらずまずここを読む。後半は各作の詳細な解析ログで、 プレイブックの各原則がどんな実例から来ているかのケーススタディとして残してある。
独自パックと独自ビットマップフォント。全テキストが表計算シート1枚。
シリアライズ済みオブジェクト内に8言語+検索キー。フォントは差し替え不要。
平文CSVを起動時に動的読み込み。ファイルを1つ足すだけで完結。
公式日本語がゲーム内に丸ごと眠っていた。壁はフォントの供給元探し。
XOR難読化コンテナ+3系統のフォント。山場は書き換えではなく書き戻し方だった。
独自拡張子の実体はただのZIP。山場は解析ではなく同梱エディタでのフォント再生成だった。
データを一切書き換えずMODで実行時に割り込む方式。フォント問題ごと消えた。
公式の日本語テキストもフォントも同梱済み。言語として登録されていないだけだった。 シナリオだけは別系統のローカライズで、そこに日本語が無かった。
8作を通じて変わらなかったのはこの14個だけ。エンジンやフォーマットの知識より先に、 この進め方を守ること自体が一番効く。
全体構造を100%理解する前に、1エントリだけ書き換えて起動テストする。表示できれば理解は合っている。 フォント・エンコーディング・改行・折り返しなど、後で致命傷になりかねない不確定要素を最初の30分で潰せる。 Lucius IIでは「フォント形式の解読」を身構えていたら、続編では標準ダイナミックフォントで 問題自体が存在しなかった。逆に何が問題になるかは、確認してみるまで分からない。
先頭4バイトを見る前にカスタムパーサーを書き始めない。PK\x03\x04(ZIP)、
Rar!、7z\xBC\xAF のようなマジックナンバーは拡張子を偽装していても中身に残る。
Serious Sam 2の独自アーカイブ.groは中身がただのZIPで、unzipや
zipfileで即座に読み書きできた。逆にここを疑わずに「独自バイナリ形式だ」と
決めつけると、既製ツールで一瞬で終わる作業に何時間もかけることになる。
6作目までは全部「データファイルを書き換える」方式だった。だがVox Machinaeは Unity IL2CPPビルドで、テキストはシーンオブジェクトに散在し、フォントには 日本語グリフが1文字も無かった——データ改変ルートだと、抽出・書き戻し・フォント自作の 全部が高コストになる構成。そこでデータには一切触れず、プロセスに割り込んで 「表示直前の文字列」を差し替えるMODを書いた。 この方式だと、過去4作で一番時間を食った「フォント問題」が丸ごと消える (日本語は自前で画像として描いて重ねるので、ゲームのフォントを触る必要がない)。
判断基準はこう。データ側の「抽出・書き戻し・フォント」のうち2つ以上が 重そうなら、実行時フックの調査を先にやる。逆にテキストが平文ファイル1枚に まとまっているならデータ改変の方が圧倒的に速い。詳細は 「実行時フック方式」の節。
家庭用機版で公式ローカライズされた作品は、PC版のデータにも日本語が残っていることがある。
Homefrontはメニュー・字幕とも公式訳がそのまま同梱されており、翻訳作業はゼロで済んだ
(表示されていなかっただけ)。まず全アセットを「ひらがな・カタカナ・漢字の存在」で
バイナリ検索する。[-ヿ一-鿿] にヒットするファイルがあれば、それは宝の地図。
日本語が無くても、他言語の公式データが「更新版の設計図」になる。 Serious Sam 2では旧版(2007年)の非公式日本語化を今のバージョンに移植する際、 最新パッチで追加されたドイツ語版のファイル一覧をそのまま「現行版で翻訳が必要な ファイルパス・フォルダ構成の正解」として使った。ドイツ語は読めなくても、 どのファイルが存在し、どう配置されているかという構造情報だけで、 旧訳のパスがどこで変わったか(フォルダ名変更・新規追加コンテンツ)を機械的に洗い出せる。 言語が読めるかどうかと、構造の手がかりとして使えるかどうかは別問題。
Space Engineersはこの原則が過去最大の当たりを引いた回。
テキスト(*.ja-JP.resx 約9,300件・92%訳済み)だけでなく
日本語専用のビットマップフォントまで丸ごと同梱されていた。
コンソール版のために作られた資産が、PC版では選べないまま眠っていただけだった。
「フォントも探す」のを忘れないこと——テキストが見つかった時点で満足すると、
すぐ隣にあるフォントを自作する羽目になる。
公式データを見つけても、ゲームがその言語を選べるとは限らない。
Space Engineersはja-JPのresxもフォントも揃っていたのに、
言語選択に日本語が出ず、設定を強制すると起動時にクラッシュした。
原因はMyTextsの静的コンストラクタに並ぶ言語登録が26言語で打ち切られていたこと
(日本語はenum値だけ存在して登録行が無い)。1行分のILを足しただけで解決した。
見分け方はこう。クラッシュのスタックトレースが「翻訳の読み込み」ではなく 「言語テーブルの参照」で止まっているなら、データではなくコードの問題。 逆に読み込みまで進んで文字が出ないならデータかフォントの問題。 翻訳を1件も書かないうちに、まず「その言語を選べる状態」を作る—— ここが通らないと、以降の実機確認が全部できない。
「パースできた」は「正しい」の証拠にならない。件数が変わる/文字化けする/クラッシュするのは 全部シグナル。Lucius IIでは同じデータを2回誤読し、2回とも実機の "MISSING TRANSLATION" や クラッシュで気づいた。パース終了位置がファイル境界・オブジェクト境界とちょうど一致するかを 最終検証にする。
依頼者に「起動しますか?」と聞くたびに1往復かかる。起動 → 生存確認 → 終了を スクリプト化すれば、同じ時間で10回試せる。Deadpoolでは3回続けて起動不能を出してから これを作り、そこから原因特定まで一気に進んだ。最初に作るべきだった。
ただし正規の起動経路を使うこと。Deadpoolは実行ファイルを直接叩くと 正常なビルドでも1秒で 0xC0000005 になる(ランチャー経由が必須)。 これに気づかず障害オフセットまで採取して、丸ごと無駄にした。 新しい計測手段を導入したら、まず既知の正常ビルドで対照を取る。
「AとBを同時に変えて壊れた」なら、Aだけ・Bだけの2本を作る。仮説を並べて考え込むより、 答えが2択になる実験を1本作る方が速い。Deadpoolの決め手は 中身を1バイトも変えず末尾に4バイト足しただけのビルドで、これが落ちたことで 「中身ではなくサイズが原因」と確定した。
分解の軸は「中身」と「大きさ」、「量」と「書式」の2つが特に効く。 1件だけ足しても落ちるなら規模ではなく書式の問題、と即座に分かる。
そして「1手で仮説を殺せる実験」があるなら、実装より先にそれをやる。 Vox Machinaeで選択肢ボタンの文字が消える不具合を「自分のMODが元テキストを 消しているせいだ」と診断し、診断用のコードを書き始めた。実際には 辞書から該当の2エントリを消すだけ——コード変更ゼロ、リスクゼロ——で 「MODが原因かどうか」は確定できた。やってみたら症状は変わらず、仮説は間違いだった (ゲーム本体が空文字列を渡していた)。 コストの高い検証を始める前に、「もし自分の仮説が正しいなら結果が変わるはずの、 一番安い操作」は何かを一度考える。
参照を辿るより破壊テストの方が速くて確実。候補ファイルを1つずつ壊して実機で変化を見る
(詳細は次節)。Homefrontではフォントの供給元がIpDrv(ネットワーク用パッケージ)という
推測では絶対に辿り着けない場所にあり、総当たりの無効化テストだけが答えを出した。
本体のテキストを全部訳しても、シナリオ・キャンペーン・DLCの中身だけ英語のまま
ということが起きる。Space Engineersでは本体がContent/Data/Localization/の
resxを読む一方、シナリオは各シナリオのフォルダに自前のローカライズ一式を持っていて、
こちらには日本語ファイルだけが用意されていなかった。
依頼者から「進行表示だけ英語のまま」と指摘されて初めて気づいた。
探し方は決まっている。コンテンツ側のフォルダ
(Scenarios//Campaigns//Missions//DLCフォルダ)を
ローカライズ形式の拡張子で再帰検索する——本体で使われていた形式が
そこにも転がっているなら、それが第二のローカライズ系統。
「本体の翻訳が終わった=完了」と判断する前に、必ずこの検索を1回かける。
そして既存言語のファイル一覧を数えると、抜けが機械的に分かる。 Space Engineersでは9言語×15コンテキストが揃っている中で日本語だけゼロ、 という形で欠落がはっきり見えた。
自分の実機確認は毎回まっさらな新規開始だった。利用者は違う——前のバージョンで 始めたセーブを、更新後に続きから遊ぶ。Space Engineersではセーブが 「読む言語ファイルの一覧」と「表示中のクエストログの文字列」を保存しており、 パッチ前のセーブは訳文が揃っていても英語のままだった。自分の検証では一度も踏まず、 配布後の報告で初めて分かった。
「まだ英語」と報告されたら、まずその文面を訳文ファイルで検索する。 入っていれば原因は翻訳ではなく読み込み側。そして検証手順に 「翻訳を外した状態でセーブを作る → 翻訳を戻す → 続きから読む」を1回入れる。 インストーラーも同じで、旧版を入れた状態からの上書き更新と アンインストール後に旧セーブを読めるかまで試す。
メニュー・ムービー字幕・会話字幕・目標表示・収集アイテム——同じゲーム内でも供給元も フォントも別々のことがある。HomefrontもDeadpoolも実際に全部バラバラだった。 検証時は必ず面ごとに個別に確認する。「メニューが変わらない=効いていない」と判断して、 実は同じ変更でムービー字幕だけ変わっていた、という取りこぼしを何度もやった。
Deadpoolではフロントエンドとゲーム中で同じフォントの別コピーが使われていた。 メニューだけ完璧でゲーム中が全部豆腐、という症状になる。「効いていない」の原因は、 たいてい直した場所が足りないと考える。
Lucius IIとIIIは両方Unityだが、テキストの格納方式もローカライズの仕組みも全くの別物だった。 HomefrontとDeadpoolは両方UE3だが、コンテナ形式もフォント系統も別物だった。 前作のコードやツールをそのまま移植できるという期待は捨て、毎回ゼロから「エンジン別の初動チェック」 をやり直す。使い回せるのはコードではなく「進め方」の方。
メニューや実績など数十〜数百件の塊でパイプライン全体(抽出→翻訳→ビルド→実機確認)を 検証してから、字幕やアイテム説明など数百〜数千件のカテゴリに進む。手戻りの母数を最小化する。 Homefrontでも字幕は1ミッション分だけ先に適用して確認してから、残り全ミッションに広げた。
「このフォント/このテキストは、本当にゲームが読んでいるのか?」—— 参照関係を静的に辿って答えを出そうとすると、シークフリー化・重複コピー・実行ファイル内の 解決ロジックに阻まれて、いくらでも時間が溶ける。壊して観察する方が桁違いに速い。 Homefrontではフォント特定に静的解析で何時間も費やして失敗し、この手法に切り替えて解決した。
| 手法 | やること | 分かること |
|---|---|---|
| ゴミ化テスト | ファイル全体をランダムバイトで上書きして起動 | そもそも読まれているか。起動できてしまえば、そのファイルは(少なくとも起動時には)読まれていない |
| 改変テスト | 構造は壊さず中身だけ露骨にずらす(グリフ座標を+17pxずらす等) | 実際に描画に使われているか。読まれてはいるが使われていない、を切り分けられる |
| 無効化テスト | 表示されなくなる値にする(グリフの幅と高さを0にする) | 改変テストより判定が明確。小さい文字は「ずれ」が見えないので、消える方を使う |
改変・無効化テストはファイルサイズを1バイトも変えないように設計する。 座標を書き換える・0で埋める、といった同サイズの上書きなら、オフセットテーブルの再計算も エクスポート表の修正も一切不要で、壊すリスクがほぼゼロになる。 テスト用パッチを何十個も作って総当たりするのだから、1回あたりのコストを下げることが効いてくる。
「変化なし」を根拠に候補を除外していく手法なので、そもそもパッチが当たっていないと
全部の判定が狂う。書き換えたファイルを読み直して意図通りの値になっているかを機械的に検証し、
デプロイ後はmd5sumでゲームフォルダ側と一致することも確認する。
Homefrontでは「パッチは正しい・ゲームは読んでいる・なのに変化しない」という矛盾が続き、
最終的に単に候補を1つ見落としていた(IpDrv_LOC_int.upk)だけだった。
候補を挙げる段階の取りこぼしが、そのまま「原因不明」に化ける。Homefrontでは
拡張子(.upkだけ見て.mapを見落とし)、
圧縮パッケージ(ヘッダが読めず走査から漏れる)、
集計スクリプトのバグ(全件を1パッケージに誤集計)の3つで、それぞれ別の候補を見逃した。
Deadpoolでも同じ罠を踏んだ。SwfMovie というクラス名を生バイト検索したら
1,787パッケージ中8件しか見つからず、それを前提に「フォントは全部直した」と誤って結論した。
実際は圧縮パッケージの名前表が読めていなかっただけで、全パッケージを開いて
エクスポートのクラス名で判定し直すと、直すべきフォントのコピーが別の場所に見つかった。
生バイト検索は「見つかった」の証拠にはなるが、「無い」の証拠には絶対にならない。
最終的に「ゲーム全体で該当クラスのオブジェクトは何個あるか」を数え、既知の内訳と 合計が合うかを確認して、初めて漏れが無いと言える状態になる。
字幕フォントに日本語を1文字足しただけで、ロード開始1秒でアクセス違反になった。 しかも中身の検査は全部通っていた——出荷データを自作エンコーダで書き戻すとバイト完全一致、 生成した全グリフの値は範囲内、パッケージの構造検査も通過。それでも落ちる。
原因は書き換えた中身ではなく、書き出し方だった。 本体を先頭から詰め直す実装だったので、大きくしたオブジェクトより後ろが全部ずれた。 ずれた中に自分の絶対ファイルオフセットを内部に持つバルクデータ (音声データ 133個ほか)が含まれていて、それを書き直す処理はどこにも無かった。
なぜ今まで無事だったかというと、それまで触っていたフォントがたまたま後ろの方に あったから。同じ操作でも、ファイル内の位置で結果が変わる。
| 大きくしたオブジェクト | 位置 | ずれる数 | 結果 |
|---|---|---|---|
| Fonts_GFX(Scaleform) | 2745 / 2830 | 84 | 起動する |
| Texture2D_415(アトラス) | 2800 / 2830 | 29 | 起動する |
| SubtitleFont(字幕フォント) | 7 / 2830 | 2822 | クラッシュ |
新しいペイロードが元の場所に入るならその場に上書き、 入らなければファイル末尾に置いて、そのオブジェクト自身のオフセット項目だけ書き換える。 他のオブジェクトは1バイトも動かない。元の場所に死んだ領域が残るが、誰も読まないので無害。 オブジェクトのデータがテーブル順に並んでいる必要は無い、という性質を使っている。
そして書き出した後にツール自身に検査させる。「意図したもの以外は動いていないか」を 毎回確認すれば、この落とし穴は二度と踏まない。
layout: 2 of 2830 exports moved (SubtitleFont, Texture2D_415)
オブジェクトが自分のオフセットを内部に持つ形式すべてで同じことが起きる (Unity SerializedFile、各種アーカイブ、一部のフォント形式)。手順は2段構え—— 第一手は「サイズが変わらない編集方法を探す」 (Homefrontの字幕は配列内2要素の入れ替えで済み、オフセット修正が完全に不要になった)。 それが無理なら第二手が「末尾に逃がす」。 「全部詰め直して全オフセットを再計算する」は、書き直し漏れが必ず出るので最後の手段。
自作エンコーダの検証として出荷データを読んで書き戻し、バイト一致を確認するのは 今でも最有力の手法(DeadpoolのDefineFont3ではこれで2つのバグを見つけた)。 ただしこれが証明するのは書式の正しさだけで、 配置・参照・エンジン側の前提までは何も言っていない。 Deadpoolはまさに「往復はバイト一致、なのに起動しない」という状態で数時間溶かした。 形式が正しいことを確認したら、次は「置き場所」を疑う。
実行ファイルと隣接フォルダを見れば、エンジンはだいたい一発で分かる。分かった時点でこの表を引く。
| エンジン | 見分け方 | テキストの典型的な置き場所 | まず入れる道具 |
|---|---|---|---|
| Unity (Mono) |
*_Data フォルダ + UnityPlayer.dll。*_Data/Managed/Assembly-CSharp.dll があればMono |
①シリアライズ済みオブジェクト内(resources.assets/level*/mainData)② StreamingAssets直下の平文ファイル(csv/json/xml等)— 両方確認すること、作品によって片方だけ、両方併用もある |
pip install UnityPy dnfile dncil(バージョンは UnityPy.load() 後 env.file.version) |
| Unity (IL2CPP) |
GameAssembly.dll + *_Data/il2cpp_data/。Managed/フォルダは無い |
上と同じ場所も見るが、C#がネイティブコードにコンパイル済みなのでDLL書き換えは不可。 テキストがシーンオブジェクトに散っている場合、データ改変は現実的でなくなる。 その時は実行時フック方式(次節)へ切り替える | MelonLoader(version.dllを置くだけで導入、初回起動時に
MelonLoader/Il2CppAssemblies/Assembly-CSharp.dll を自動生成してくれる)+
Harmony。この生成DLLはメタデータだけの抜け殻だが、クラス名・メソッド名・
シグネチャの調査にはこれで十分 |
| UE3 (〜2013) |
Engine/ + *.upk / *.u / *.xxx / *.map、Coalesced* |
①Coalesced(.iniと各言語のローカライズファイルを丸ごと格納。世代で別物——2011はCoalesced_int.binで平文、2013のDeadpoolはCoalesced.intでXOR難読化)②<Game>/Localization/<LANG>/*.<lang> の平文 ③音声アセット内に埋め込まれた字幕(SoundNodeWave.LocalizedSubtitles) |
umodel(UE Viewer)+Gildorのdecompress.exe(パッケージは圧縮されている)+UE Explorer/UELib。.locres系のUE4ツールは一切使えない |
| UE3 フォント3系統 |
上と同じ | 1本のゲームに最大3系統が同居する。①Scaleform GFX(SwfMovieエクスポート内のDefineFont3タグ)=メニュー・HUD・吹き出し ②UCanvas用 Fontオブジェクト+DXT5グリフアトラス=字幕 ③スタブフォント(グリフ0個)はFontLib経由で①に解決される。どれが効いているかは破壊テストで特定する |
|
| UE4 / UE5 | Engine/ フォルダ + *.pak |
多くは .locres(FText用バイナリ形式)。まれに .pak 内の平文アセット |
UnrealLocres 等の専用OSSツール、または UnrealPak で展開してから検索 |
| RPG Maker MV/MZ | www/js/rmmz_core.js または rpg_core.js |
data/*.json にほぼ平文(イベントのテキストコマンドも同フォルダ内) |
JSON なのでテキストエディタ+自作スクリプトで十分。既存のRPGMV翻訳ツール群も流用可 |
| Ren'Py | renpy/ フォルダ、.rpa アーカイブ |
公式に多言語対応の仕組みがあり、renpy.exe generate translations 相当のコマンドで.rpy形式の翻訳ファイルを自動生成できる |
まず公式の翻訳生成機能を疑う。独自パーサーは基本不要 |
| Godot | .pck、実行ファイル同梱の project.godot |
公式の多言語対応(.csv/.po 翻訳ファイル)が使われていることが多い |
gdsdecomp 等でpckを展開して project.godot の [locale] 設定を確認 |
| Serious Engine (Croteam) |
Bin/フォルダ+独自拡張子.groアーカイブ |
.groの中身は実はZIP(PK\x03\x04)。ロケールは
Content/<Game>/Locales/<言語名>/以下に元パスを再現した
重ね置きで上書きされる。フォント・レベル・図鑑テキストは全てこの中の
通常ファイルとして入っている |
Bin/に開発元のレベルエディタ(SeriousEditor2.exe等)が
同梱されていないか確認する。フォント生成を含む正規のツールチェーンが丸ごと使える
ことがある |
| VRAGE 2 (Keen Software House) |
Bin64/にVRage*.dll群+Content/Data/*.sbc(XML定義)。
.NET Framework製なのでDLLがそのまま逆コンパイルできる |
Content/Data/Localization/の.NET標準の.resx(MyTexts.<culture>.resx)。
平文XMLで、キー=文字列ID。基底ファイル→言語→サブカルチャの順に重ねて読むので、
未訳キーは自動的に英語へフォールバックする。
フォントの言語別割り当てもContent/Data/Fonts.sbcというデータ側にある |
dotnet tool install -g ilspycmd(読む)+dnlib(書く)。
言語一覧はコード内のハードコードなので、resxを足すだけでは選べないことがある——
まずMyTexts/MyLanguage相当のクラスを読む。
そしてContent/Scenarios/*/Localization/を必ず別途見る——
シナリオは<Context>.sbl(マニフェスト)+.resxという
本体とは独立した第二のローカライズ系統を持っている |
| 独自/不明エンジン | 上記どれにも当てはまらない | 総当たりで特定:既知の英語フレーズをバイナリ検索(data.find(b"..."))してヒットしたファイルから逆算。その前に必ず先頭バイトでZIP/RAR/7z等の
既知マジックナンバーを確認する——独自拡張子の中身が標準フォーマットの
偽装であることは珍しくない |
Pythonの re+バイナリ読み込みで十分。フォント形式の解読は最後の最後まで後回し。
同梱の開発ツール(エディタ・SDK)が無いかも先に一通り確認する |
既知の英文がどのファイルにも見つからない場合、難読化を疑う。統計で崩せることが多い
(DeadpoolのCoalesced.intは61文字の鍵によるXORで、頻度分析から鍵長と鍵が復元できた)。
崩せたら、必ず実行ファイル内にその鍵の文字列リテラルが無いか探す。
Deadpoolでは.rdataにUTF-16で丸ごと入っていた。
推定した鍵が実物と一致すれば解析が正しい強い証拠になり、実装も単純化できる。
Unity(Mono/IL2CPP以外)や.NET製ゲームで、テキストの読み込みロジックそのものを確認したいが
ilspycmd 等のフル逆コンパイラを用意できない・使いたくない場合、
dnfile(.NETメタデータの読み取り)と dncil(ILバイトコードのデコード)を
組み合わせれば、特定のメソッド1個の逐語的な挙動をピンポイントで確認できる。
C#のソースは出てこないが、命令列と文字列・呼び出し先メソッド名は正確に読めるので、
「区切り文字は何か」「最初に読んだファイルだけ特別扱いされていないか」のような
構造仮説の検証には十分。
pip install dnfile dncil → dnfile.dnPE(dll_path) でTypeDef/MethodDef一覧から
目当てのクラス・メソッドの Rva を取得 → pefile 由来の get_data(rva, n) で
メソッド本体を読む CilMethodBodyReaderBase を自作 → CilMethodBody(reader).instructions を
ダンプして ldstr(文字列リテラル)と call(呼び出し先)だけ拾えば、
処理の流れは十分追える。クラス名の見当がつかない時は、DLLをUTF-16文字列として総当たり検索し
(.NET文字列リテラルはUTF-16LEでIL内に直接埋め込まれる)、ヒットした文言から関連クラスを逆引きする。
UE3では、既存OSSライブラリ(Eliot.UELib.dll/UE Explorer)を
小さな自作C#プログラムから直接叩いて、自前パーサーの答え合わせに使った。
Windows同梱の C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe があれば
.NET SDKを入れなくてもコンパイルできる。自作パーサーとライブラリは独立した実装なので、
両者が一致すれば構造理解が正しい強い証拠になる(自作パーサーだけだと、
読み書き両方が同じ勘違いをしていても気づけない)。
既存の.NETアセンブリに数命令を足すだけなら、dnlib(dotnet add package dnlib)で
ModuleDefMD.Load → 目的のメソッドのBody.Instructionsに
Instruction.Create(...)をInsert → Write、で済む。
Space EngineersではMyTexts..cctorの末尾に
AddLanguage(Japanese,"ja","JP",...)の7命令を挿しただけだった。
書き出しはMetadataFlags.PreserveAllを付けて、触っていない部分の
メタデータ配置を保存する。読む側はilspycmd(dotnet tool install -g ilspycmd)で、
-t <完全修飾型名> を付ければ1クラスだけC#に戻せる。
最新版がSDKと噛み合わないことがあるので、失敗したら--versionで1〜2世代落とす。
Space Engineersでパッチが完璧なのに何も変わらないという状態に1時間溶かした。
Bin64/VRage.dllを書き換え、PowerShellのAssembly.LoadFromで
リフレクションして「確かに新しい値が入っている」ことまで確認したのに、
ゲームだけが旧挙動のままクラッシュし続けた。
犯人はNGen(事前コンパイル)済みのネイティブイメージ
C:\Windows\assembly\NativeImages_v4.0.30319_64\VRage\…\VRage.ni.dllで、
CLRはIL側ではなくこちらを実行していた。
判別はGet-Process <game> | %{ $_.Modules } に
*.ni.dll が並ぶかどうかで一発。
回避策はモジュールのMVIDを変えること——dnlibなら
mod.Mvid = Guid.NewGuid() を書き出し前に入れるだけで、
CLRがネイティブイメージを「別物」と判定して拒否し、ILからJITし直す。
管理者権限もngen updateも要らない。
そして、この種の「効いているのか効いていないのか分からない」状況では、
効いていれば必ず壊れる仕掛けを1つ入れるのが最速——
今回は同じキーを2回登録するILを仕込んだ。
パッチが実行されていれば重複キー例外で別のクラッシュになるはずで、
実際は元のクラッシュのままだったので「実行されていない」が1回で確定した。
ゲームのプロセスにMODを読み込ませ、文字列が画面に出る直前のメソッドに割り込んで 差し替える方式。データファイルは1バイトも書き換えない。 Vox Machinae(Unity IL2CPP)で採用し、7,000件超を実機で表示させるところまで到達した。
核心は「ゲームには何も描かせず、自分で描く」こと。これで フォント問題が構造的に消える——ゲームのフォントに日本語グリフが1文字も無くても関係ない。
UIフレームワークには、たいてい表示テキストの最終的な設定先が1つある
(Vox MachinaeではGooeyElement.SetSymbol(string)で、
86画面ぶんのメニューが全部ここを通っていた)。
ここにHarmonyのPrefixを当てれば、ゲーム全体のUIを1つのパッチで押さえられる。
Prefixで引数を""に書き換えると、ゲームは何も描かなくなる。
同時に、その要素のTransformと訳文を自前の辞書に控えておく。
OSのフォント描画(WindowsならDrawTextW)で訳文を
BGRAビットマップにレンダリング → PNGにエンコード → Texture2Dに読み込む。
ゲームのフォント資産には一切触れない。
テクスチャは文字列+サイズ+色+折り返し幅をキーにキャッシュする
(毎フレーム生成すると即死する。色と幅をキーに入れ忘れると、同じ文が別の色・幅で
必要になった時に古い画像を使い回してしまう)。
ここが方式の成否を分ける。UnityのOnGUI()は
デスクトップのウィンドウにしか描かれず、VRゴーグルの映像には一切入らない。
当初Camera.main.WorldToScreenPoint()で投影してGUI.Labelで描いていたが、
VRで起動したら英語は消してあるのに日本語も出ない=全部空欄になった
(モニタ側では正常に見えるので、非VRだけで検証していると気づけない)。
正解はテクスチャを貼った板(Quad)を、置き換えた要素の子として3D空間に置くこと。
ゲームのカメラが描くので両目の映像に入り、位置・向きも親に追従する。
非VRでも同じコードで動く。OnGUIは失敗時の退避用としてだけ残す。
大きさ——UIフレームワークは「1文字の大きさ」を持っていることが多い
(Vox MachinaeではGooey.letterHeight)。それを1行の高さとして
板の大きさ = テクスチャ画素数 × 文字高 ÷ レンダリング画素数で決める。
ただし要素のスケールが既にTransformに入っているなら二重に掛けない
(掛けてしまい、板が2mmの点になって「描画されているのに見えない」状態を作った)。
位置——要素はpivot(-1/0/+1のような値)で左右上下の揃えを持つ。
中央固定にすると隣のアイコンに重なるので、板の位置 = pivot × 板の大きさ ÷ 2
でずらす。画像内の行揃えも同じ値に合わせる。
奥行き——UIの文字はモニターのガラスや筐体を透過して描かれていることが多い。
普通に深度テストする板を同じ位置に置くと描画はされているのに奥に隠れて見えない。
Unity標準のGUI/Text Shader(TextMeshのシェーダー)はZTest Always固定なので、
これを使い、描画順はゲームのUIマテリアルのrenderQueue + 1にすると
汚れや反射のオーバーレイまで含めて元の文字と同じ見え方になる。
UI要素はプールされて使い回されるので、放置すると前の画面の訳文が残る。 ただし「見えないから破棄」は危険——要素は生成直後は不透明度0で、 そこからフェードインすることがある。破棄する実装にしたら、 作った次の瞬間に自分で消してしまい、メニュー項目が1つも出なかった (毎フレーム処理が、ゲームがフェードを進める前に走っていた)。
板が要素の子なら、親が消えれば一緒に消えるので破棄は不要。 表示/非表示の切り替えだけにして、別用途に使い回された時は 文字列設定のフックが更新・削除するのが正しい。 不透明度も要素の「色のアルファ」まで掛ける——選択中だけ明るい、といった ゲーム本来の演出がそのまま再現でき、隠されているはずの文字が光る事故も防げる。
要素はデータの入れ物にすぎず、描画しているのは親のレンダラーということがある。 Vox Machinaeの字幕要素はレイヤー2(Ignore Raycast)にあり、 コックピットのカメラがそのレイヤーを描かない場面で、字幕だけ消えた (船内では出るのにコックピットでは出ない、という分かりにくい症状になった)。 板のレイヤーは、要素ではなく描画器のGameObjectから取る。
この方式の不具合は「板が作られていない」「作られたが描画されていない」
「描画されているが見えない」の3つに分かれ、症状はどれも同じ「何も出ない」。
切り分けは簡単で、カメラの正面30cmに、単色の大きな板を1枚出す。
これが見えれば描画の仕組み(シェーダー・メッシュ・レイヤー)は正常で、
原因は位置・大きさ・奥行きのいずれかに絞られる。
あわせてRenderer.isVisible(前フレームに実際に描画されたか)を
ログに出すと、「描画はされている=隠れている」まで一発で確定する。
実際この2つで、2mmの点になっていた件とガラスの奥に隠れていた件を順に潰せた。
SetSymbol()を押さえたのに一部のテキストだけ英語のままだった。
原因は、それらの初期値がUnityのシーン復元によってフィールドへ直接書かれていて、
セッターを一度も通らないこと。「値を設定するメソッド」ではなく
「値を画面に反映するメソッド」(Vox MachinaeではRefreshSymbol())にも
パッチを当て、そこでフィールドの現在値を読むと全部拾えた。
セッター系とコミット系の2点を押さえるのが定石。
画面の隅のガイド表示だけ、元テキストは消えるのに訳文も出ない、という症状が出た。
その要素は別のアニメーション用アルファ(alphaAnimationValue)で
表示されていて、素のalphaは0のままだったため、
前述の「見えていない要素を捨てる」処理が毎フレーム削除していた。
ここで全要素にmax(alpha, animAlpha)を適用する修正は大事故になる
(非表示のはずのパネルが何十枚も同時に描画された)。
正解は親オブジェクト名で対象を絞った例外扱いにすること。
可視判定の例外は、必ず「どの要素に対してか」を限定して入れる。
これが実行時フック方式で一番効く部分。割り込んだ場所で受け取った文字列を
全部ログファイルに追記しておけば(重複はHashSetで除去)、
あとはそのログと翻訳辞書を突き合わせるだけで
「まだ英語で出ている文字列」が機械的に列挙できる。
静的解析で「これが表示されるはず」と推測する必要がそもそも無くなる。
前半の破壊テストが「どのファイルが使われているか」を答えるのに対し、
これは「実際に画面に出た文字列は何か」を答える。
MODローダーのロガーはバッファされることが多く、ゲームを強制終了すると
直前の数秒が丸ごと消える。診断ログはFile.AppendAllTextのような
1行ごとに確定する方法で、自前の別ファイルに書く。
Vox Machinaeでは MelonLoader のLatest.logが落ちる一方、
自前のmenu_diag.logは毎回確実に残り、これだけが頼りになった。
実際、この突き合わせが2つの根本原因を一撃で出した——
①メニュー文字列が"BASIC|TRAINING"のようにパイプ区切りで改行していたこと、
②確認ダイアログが"回答1|回答2|質問文"という1本の連結文字列で
渡されていたこと。どちらも辞書のキーが実際の文字列と違っていただけで、
訳文は最初から存在していた。推測を2回外した後に、ログを見て30秒で解決した。
調査用のフックを1つ足したら、ビルドは成功したのに実行時に字幕翻訳のパッチが
適用に失敗し、フォント初期化まで例外になった。毎回出ていた
Class::Init signatures have been exhausted, using a substitute!という警告から
「Il2CppInteropの型解決が容量ぎりぎりで、対象を増やしたせいで押し出された」と結論し、
以後この方式では新しいフックを足せない、とまで書いた。これは完全な誤診断だった。
真相は配置したDLLが別物だったこと。プロジェクトの出力先が
net6.0-windowsに変わっていたのに、古いnet6.0フォルダに
数週間前の試作DLLが残っていて、そちらをコピーしていた。
その試作版は当時Unityのフォントを使っており、字幕パッチの引数名も古かった——
だからエラーの内容も、今のソースとは噛み合っていなかった。
気づいた決め手は、エラーがFont.CreateDynamicFontFromOSFontという
「ソースのどこにも書いていない関数」を指していたこと。
同じ勘違いで2回、無関係なコードを疑って作業を止めた。
教訓は3つ。①エラーが自分のソースに存在しないコードを指したら、
まず「本当にこのビルドが動いているか」を疑う——コードの正しさより先に、同一性を確認する。
②配置は必ずスクリプト化し、ビルド系に出力先を聞く
(dotnet msbuild -getProperty:TargetPath)。
出力がビルド開始時刻より古ければ中止、コピー後はハッシュ照合まで自動でやる。
③それでもMODのコードを変えたら起動ログを[ERROR]で検索し、
過去の正常起動時のログと比較する(MelonLoader/Logs/に全部残る)。
なおClass::Init signatures have been exhaustedは毎回出る無害な警告で、
フックを増やしても問題は起きなかった。
MODローダー本体・MOD DLL・翻訳辞書・ソースを1つのzipにまとめ、 ゲームフォルダに上書き展開するだけにする。データを書き換えないので バックアップも復元処理も要らない(アンインストールは削除するだけ)。 前半「配布パッケージの型」の追加方式が理想形のまま実現できるのは、この方式の副次的な利点。 ただしゲーム本体のアップデートでクラス名やメソッドが変わると動かなくなるので、 動作確認済みのバージョン番号をreadmeに明記し、ソースも同梱しておく。
行番号やファイル内の物理位置ではなく、ゲームがルックアップに使う値(連番ID・ハッシュ・
検索キー文字列など)を主キーにしたJSON({"id": {"key": ..., "en": "..."}})に抽出する。
表示順と保存順が一致する保証はどのフォーマットにもない。
原文が英語そのものを検索キーにする方式(=英文→訳文の辞書)の場合、 抽出時に1文字でも余計なものが混ざると、そのエントリは永久に一致しない。 しかもエラーは何も出ず、ただ英語のまま表示されるので 「まだ訳していない箇所」と見分けがつかない。
Vox Machinaeでこれを大規模にやった。台本の台詞には
Frank:<a3>text というボイス収録用のマーカーが付いていて、
しかもコロンとマーカーの間に空白が有る行と無い行が混在していた。
抽出の正規表現が空白無しの形しか想定していなかったため、空白有りの行では
マーカーが本文の先頭に貼り付いたままキーになり、
1,736件が「訳文は完成しているのに絶対に一致しないキー」として登録されていた。
値の側にもマーカーが残っていたので、キーだけ直すと今度は画面に
<a3>が表示される、というおまけ付きだった。
対策は2つ。①抽出直後に、キーの集合を
「記号・タグらしきものを含む行」で機械的にふるいにかけて目視する。
②HTMLエンティティ("/')の復元を忘れない——
これを忘れると、実際には一致している数千件が「未翻訳」として誤検出され、
存在しない問題を追いかけることになる(実際に1,800件の偽陽性を出した)。
そして症状の読み方を変える。「訳したはずなのに英語で出る」時、 原因は訳文ではなくキーである可能性の方が高い。
UI部品が受け取る文字列は、必ずしも画面で読める文字列と同じではない。
Vox Machinaeの確認ダイアログは
"回答1|回答2|質問文"という1本の連結文字列で渡ってきていた
(区切りは|)。質問文だけを辞書のキーにしていたので永久に一致せず、
正しいキーは"||質問文"——先頭の||は
回答ラベルが2つとも空という意味だった。
同じゲームのメニューでは|が改行として使われており
("BASIC|TRAINING")、同一記号が文脈で別の意味を持っていた。
区切り文字を見つけたら、意味を1つだと決めつけない。
実際に渡ってきた文字列をログで確認するのが唯一確実な方法。
台本形式のデータは条件分岐やマクロの中に台詞が埋め込まれている。
Vox MachinaeのTwine台本では、2回目以降の会話や分岐後の台詞がこう書かれていた——
(if: $i1engineroom is 1) [Hans: <h4>Here's an unfamiliar face.]]。
抽出器が「行頭が話者名:で始まる行」しか拾っていなかったため、
この形の863行を丸ごと取りこぼしていた。
症状は「NPCに2回目に話しかけると必ず英語」——
一度目は普通の行、二度目以降は分岐の中、という構造がそのまま現れる。
対策。話者は行頭だけでなく[・]・)の直後でも
受け付け、本文は次の]までとする。
(の直後は除外すれば(if:/(set:のような
マクロ名を話者と誤認しない。「1行に複数の台詞」もあり得るので、
行ごとに全部の出現を走査する。
そして完了判定を「抽出できた件数」ではなく「原文側の総数」で行う。 修正後は総数が5,744→6,607に増えた。母数が増えるということは、 それまでの「100%カバー」は分母が間違っていたということ—— カバー率は分母を疑ってから読む。
原文に{0}や___のような穴があり、
ゲームが実行時に埋めてから表示することがある。埋まる中身は
入力機器や相手の名前で変わるので、完成後の文字列を辞書に登録しても追いつかない
(press W,とpress Left Stick Up,で別物になる)。
Vox Machinaeではこの形の辞書エントリが139件あったのに、一度も一致していなかった。 非VRで動いて見えたのは、プレイ中に拾ったキーボード専用の完成文字列を 個別に登録していたから——「動いている」が「仕組みとして正しい」とは限らない。
実装は単純。穴を含むキーを、穴を(.+?)に置換した
アンカー付き正規表現に変換し、辞書が外れた時だけ順に試す。
literal部分が長い順に並べる(「Then {0} to fire at the target.」が
「Then {0} to fire.」に食われないように)。
literalが短すぎるキーは除外する({0}.は全文に一致してしまう)。
捕まえた値は、そのまま埋め戻さず日本語に馴染む形に整える。
press W, and S,→[W]・[S]、
press Space, and hold it→[Space]長押しのように
動詞を落として名詞化し、日本語側は{0}で…/{0}を押して…と書く。
逆に日本語を{0}してくださいと書いてはいけない——
[Right Stick Button]してくださいになる。
原文ファイルを直接編集せず、キー → 訳文のオーバーレイを別に持ち、ビルド時に合成する。
オーバーレイは<file>.part01.json のように150〜200件で分割してよい
(全部をソート順にマージする)。1回のセッションが中断しても成果がチャンク単位で残り、
原文はいつでも正典として参照できる。同じ英文が複数キーに現れるファイルでは、
英文 → 訳文の対応表を別に用意して一括展開する方が速い。
ビルド時、オーバーレイに無いキーは英語(原文)でそのまま埋める。 こうすることで、翻訳がまだ半分でも赤字の "MISSING TRANSLATION" のような プレースホルダーがプレイヤーの目に触れることがない。作業を段階的に配布・検証できる。
ただし「キーが載っていること」を翻訳済みの印にすること。 「訳文が空でないこと」で判定すると、意図的に空にすることができなくなる(次項)。
Key1 + <ボタン> + Key2 のように分割された文字列は、英語の
"Press / W / to get up off your ass." をそのまま置き換えると
「押せ W でケツを上げろ。」という不自然な日本語になる。日本語は助詞がボタンの後に来るので、
前半を空にして後半に動詞を移す(「W を押してケツを上げろ。」)。
分割文字列を見つけたら、まず実際の連結順を確認してから訳す。
<br>、*強調語+、{ButtonStart}、`c のような
ランタイム置換記法は、原文と訳文でトークンの集合が一致するかを機械的に照合する。
目視では必ず漏れる。Deadpoolではこの照合が製品版側のバグまで見つけた
(同じキーが3回代入されていて最後の1回が勝つ、という構造だった)。
同時に「想定した文字集合の外」の検出も入れる。 訳文に紛れ込んだキリル文字(ラテン文字そっくりで目視では気づけない)を、 この検査だけが捕まえた。
キャラクター名・地名などは初出時にカタカナ表記を確定し、以降は用語集代わりに 検索して同じ表記で統一する。数百件に分けて訳す作業ほど表記ゆれが起きやすい。 キャラクターごとの一人称・語尾も最初に決めると、数千行でも人格がぶれない。
最初に決めていても、数千行を分割して訳せば必ずぶれる。 Vox Machinaeでは依頼者から「AIキャラのBLUEが時々タメ口になるのが気になる」と 指摘された。全2,000行を読み直す代わりに、次の3段階で機械的に潰した。
①台本から話者ごとに台詞を抽出(話者名:で始まる行を集める)。
②各訳文の文末を正規表現で丁寧語/常体に分類
(です・ます・ください・ました / だ・だな・くれ・んだ・ろ など)。
③キャラごとに多数派を「そのキャラの正しい口調」と見なし、少数派だけを列挙して手で直す。
正解を自分で決めずに多数決で決めるのが要点——作品の設定を完全には 知らなくても、訳文自身の統計が答えを持っている。 14人ぶんで224件の外れ値が出て、修正後はBLUEが1,316件中0件のブレになった。 なおこの監査は「訳し終わった後」に一度かける工程として組み込むとよい。 指摘されてから慌ててやるより、全キャラ一括の方が明らかに安い。
ただし構造上直せないものがある。英文→訳文の辞書は
1つの英文に1つの訳文しか持てないので、
"What?"のような短い台詞を口調の違う複数キャラが話す場合、
どちらかを諦めるしかない(キーを(話者, 英文)にする設計変更が要る)。
自動修正スクリプトには「複数話者が使っている行はスキップする」判定を必ず入れる——
入れないと、Aを直した瞬間にBが壊れる。
収録する文字はメニューのテキストだけでなく 字幕・アイテム説明・実績など全ての日本語ソースを合わせた和集合から決める。 Homefrontでは字幕データの文字を集計に入れ忘れ、「奉仕」の奉だけが別の字で表示される という分かりにくいバグになった(グリフが無いとフォールバック文字が描かれる)。 訳文ファイル群から機械的に文字集合を作るようにしておけば、訳を足すたびに自動で追随する。
集合を正しく作っても、器(アトラス)の容量不足で同じ症状が再発する。
Serious Sam 2では約1,800字の文字集合を正しくフォント生成ツールに渡していたにもかかわらず、
既定のアトラスサイズ(256×128)では68字しか収まらず、残り全部が無言で代替文字に
すり替わっていた——エラーは出ず、ツールのログに一行
Not all characters fitted in the texture (68/1810) と出るだけだった。
「映」のような何の変哲もない常用漢字が、収まりきらなかった1742字の中にたまたま含まれていた、
というだけの話。文字集合を渡した後は、生成ツールの側が「全部収まったか」を
明示的に報告しているか確認する。黙って一部を落とすツールは想定より多い。
商標・著作権表記、製品版に紛れ込んだ開発用プレースホルダ
(<deleted - need line> のような文字列)は、訳すとかえっておかしくなる。
「未着手」ではなく「意図的に原文のまま」だと明示して、readmeにも書いておく。
Deadpoolは6,059行中23行がこれに当たった。
「全部読んでから訳して最後にまとめて書き込む」やり方は、 並列実行時に全滅する。Serious Sam 2では未翻訳の数百〜千行超のテーブルファイルを 5本の翻訳サブエージェントに同時に投げたところ、全員が「読了→翻訳完了→さあ書き込み」 の直前でAPIのセッションレート上限に同時到達し、5本とも訳文を1行も保存できずに 全滅した。指示に「30〜50件ごとに訳しては即座にファイルへ反映し、既に反映済みの キーはスキップして再開できるようにする」という一文を足しただけで、 再実行では同じ規模の作業が普通に完走した。大量処理を並列化するなら、 「作業単位を小さくちぎってその都度確定させる」ことを明示的に指示する。 これを怠ると、同じ共有レート制限に複数のエージェントが同時に刺さり、 1つの失敗ではなく全滅という形で表面化する。
バイナリ形式を自力で解読して自作エンコーダを書く前に、
ゲーム自身が開発元のエディタ・SDKを同梱していないかを確認する。入っていれば、
フォント(や他のリソース)を正規の方法でゼロから正しく生成できてしまうことがある。
Serious Sam 2はBin/に開発元のレベルエディタSeriousEditor2.exeが
同梱されており、その中に File → New → Document.Font という純正のビットマップ
フォント生成機能がまるごと入っていた。フォント形式のバイナリ仕様を一切解読せずに、
システムにインストール済みの日本語フォント(Meiryo等)を指定 → 訳文全体から文字集合を
読み込ませる → 生成、という3ステップで、その形式で確実に正しく動くフォントファイルが
手に入った。「解析にかかる時間」と「同梱ツールを探す時間」を天秤にかけたら、
後者を先に試す方が期待値が高い——ツールが無ければそこで初めて解析に進めばよい。
こうしたツールはCUIもスクリプトAPIも無いことが多いが、それでも スクリーンショット→座標クリック→スクリーンショットで確認、のループを回せば 十分に自動運転できる。Serious Sam 2では次の3点が実運用上のコツだった。
SendKeysが一切効かず(メッセージキューを経由しない
DirectInput/RawInputで読んでいるため)、Win32 SendInputにスキャンコードで
キーを送る必要があった。逆にエディタの標準ダイアログ・入力欄はSendKeysで
問題なく操作できた。「反応しない」と思ったら、まず入力経路そのものを疑う。Ctrl+Aやトリプルクリックはタイミングによってフィールドではなく親コンテナを
選択してしまい、新しい文字列が古い値の前後に連結される事故が頻発した。
End → Shift+Home → Deleteのキーボード操作の方が安定し、
さらに確実なのはファイル一覧から目的の項目を直接クリックしてフィールドを
自動入力させる方法(手入力そのものを回避できる)。Save実行後は毎回、対象ファイルの更新日時・サイズをOS側から確認してから
次の操作に進む。曖昧なまま連続操作すると失敗が積み重なって収拾がつかなくなる。
Serious Sam 2のフォントツールに用意されていた「テキストファイルから文字集合を読み込む」
機能は、テストしてみるとプレーンなUTF-8ファイルを渡すとASCII文字しか拾わず、
日本語部分が丸ごと欠落するという挙動だった(エラーは出ない)。
数文字だけの最小テストファイルを複数のエンコーディングで作って試したところ、
UTF-16(BOM付き)で保存し直すと正しく全角文字を1文字ずつ認識した
(内部的にワイド文字読み込みしか実装されていない古いツールだった、と推測される)。
「日本語だけ無視される」系の症状が出たら、まず入力ファイルのエンコーディングを疑い、
最小構成のテストケースで1つずつ潰す(これは冒頭の原則「1文字だけ実機に出す」の
ツール版)。
フォント記述が複数ページ(bitmap id)を前提にしている形式なら、
既存ページには一切触らず新しいページを追加して、足りないグリフだけそこに置くのが最善。
Space EngineersのXNA系ビットマップフォント(<bitmap id>+
<glyph bm="n" origin="x,y">)では、
<bitmap id="4">と306個の<glyph>を追記しただけで済んだ。
出荷ピクセルを1バイトも触らない=既存の見た目が絶対に変わらないし、
アンインストールは「足したファイルを消して.xmlを戻す」だけになる。
1024×1024に50px升目なら400字強入るので、たいてい1枚で足りる。
追加分を既存の字と揃えるのに、フォント名・サイズ・オフセットを推測で合わせる必要はない。
出荷アトラスから既知の数文字を切り出し、候補パラメータで描いた画像との
平均絶対誤差を総当たりで最小化すると、正解が一意に出る。
Space Engineersでは(msjhbd.ttc, index 0, 31px, 描画位置 +7,+3)で
平均誤差1.4/255——実質ピクセル一致まで追い込めた。
ドロップシャドウ付きの別バリアントも、同じやり方で
「白のカバレッジをぼかしてmax(cov, blur(cov)×k)」の
σとkを当てられる(誤差5/255)。
グリッド探索は数十秒で終わる。目視で合わせようとしないこと。
最近のゲームのアトラスはDXT5ではなくBC7(DXGI_FORMAT 98/99)のことがある。
全モードの実装は大仕事だが、モード6(1サブセット・RGBA各7bit+pビット・4bitインデックス)
1つだけ書けば実用品質になる——128bitの並びは
モード7bit → R0R1G0G1B0B1 各7bit → A0A1 各7bit → pビット2つ →
インデックス(先頭だけ3bit、残り15個は4bit)。
エンドポイントはブロックの成分ごとmin/max、インデックスは総当たりで最近傍、
pビットは4通り試して誤差最小を採る、で十分。
検算はPillowに読ませて元画像と差分を取ればよい(Pillow 10以降はBC7を復号できる)。
先頭インデックスの最上位ビットは0でなければならないという
アンカー制約だけ忘れずに(違反したらエンドポイントを入れ替えてインデックスを反転する)。
DXT5/BC3は4×4ブロックの行優先なので、512×512のページを1024×2048の左上に置くのは ブロック行の単純コピーで済む。再エンコードが一切要らない=出荷ピクセルが完全に保存される。 新しく足すグリフの分だけエンコーダを書けばよく、アルファのみのフォントなら 「エンドポイントを255/0固定、8段階の量子化」で十分な品質が出る。
多くのエンジンはStartU / SizeX のように描画時にテクスチャ寸法で割るので、
同じピクセル位置に同じ絵があれば、寸法を変えても既存グリフの座標はそのまま正しい。
これが「拡張」を安全にしている根拠なので、対象エンジンでもそうなっているか先に確認する。
足す文字を既存の英字と揃えるには、実測が要る。. A B の
インク下端が揃う行がベースライン。大文字の高さを測れば、そこから逆算して
日本語のem値が決まる(Deadpoolは大文字17px → Noto Sans JPのcap 733/1000 → em 24pxでほぼ一致)。
収録文字数 ≦ (アトラス幅 ÷ 升目) ×(高さ ÷ 升目)。ここが足りないと升目を小さくする= 文字が小さくなる、というトレードオフになる。寸法変更が許されるかも先に確認する (エンジンのテクスチャグループ設定に上限がある。そのゲーム自身が使っている最大寸法までなら安全)。
升目状に配置するのは実装が楽だが、1文字あたりの幅を升目そのままにすると
英数字まで全角幅で描画される。各文字の実インク領域(textbbox)を測り、
矩形は升目原点+実寸で登録する。
多くのエンジンは矩形の幅をそのまま字送りに使う。インク領域ぴったりで登録すると サイドベアリングがゼロになり文字同士が接触する。矩形=送り幅(advance)とし、 インクはその中に左サイドベアリング分だけ内側に置く。 エンジン側に字送りパラメータがあれば1〜3px入れる(拡大率に比例させること)。
1種類のサイズで全部を賄おうとすると必ず破綻する。字幕に合わせて大きくすると メニューやアイテム説明など固定枠のUIで溢れる。Homefrontでは 小(メニュー用)と大(字幕用)の2枚のアトラスを作り、フォントごとに割り当てた。 どのフォントがどの画面で使われるかは破壊テストで特定する。
実機確認は1回が高コストで、しかも見落とす。
そこで実機で起きうる失敗を、機械的に検査できる形に翻訳しておく。
Deadpoolでは毎ビルド次の3つを自動で確認し、problems: 0 でなければ導入しない運用にした。
| 検査 | 実機で起きうる失敗 |
|---|---|
| 出荷グリフが1バイトも変わっていない | 英字の見た目が変わる/既存の画面が壊れる |
| 訳文が使う全文字にグリフがある | 特定の1文字だけ豆腐・別字になる(原因が極めて分かりにくい) |
| 書いた訳文がそのままビルドから出てくる | エンコード事故・キーの取り違え・意図しない欠落 |
| 意図した以外のオブジェクトが動いていない | オフセット破壊による起動不能(前述) |
起動ログに[ERROR]が1件も無い(MOD方式の場合) | パッチの適用失敗。ビルド成功と「N件ロード」だけでは検出できない——別機能が黙って死ぬ |
| フォント定義が参照するアトラスページが実在する | フォントが丸ごと読み込めず、全画面が無表示になる。ビルドスクリプトの入力取り違えで実際に起きた |
| ビルドスクリプトが出荷ファイルを入力にしている(自分の出力ではなく) | 2回実行すると二重に適用される。「同じ入力なら同じ出力」でないビルドは、いつか必ず壊れる |
検査というと「変更が正しいか」に目が行くが、実際に事故るのは巻き添えの方。 オーバーレイの無いファイルがバイト単位で無改変であること、出荷グリフが同一であること、 他のオブジェクトが動いていないこと——この3つは全部「変えていないものの検査」で、 そして全部が実際に事故を捕まえた。
Space Engineersの起動ログには、パッチ適用後に
Error: Control for hint is missing. Context - BASE, Control - FAKE_MODIFIER_LB
が13件出ていた。調べたら自分のせいではなく、出荷されている公式日本語resxのバグだった——
ゲームパッド操作ヒントのコントロールIDが入るべき欄が、旧IDのまま/
あまつさえジャンプ・ターミナルと日本語に翻訳されていた。
英語版の値に同期させたら0件になった。
「エラーが出ている=自分が壊した」でも「エラーが出ていない=正しい」でもない。
製品版の同条件と比べて増減を見るのが唯一の判断材料で、
ついでに公式ローカライズ自体のバグも拾える。
本体のテキストを訳し終えて実機で確認し、配布まで済ませた後に、 依頼者から「シナリオ上の進行情報だけ英語のまま」と指摘された。 原因は翻訳漏れではなく、ゲームがローカライズ系統を2つ持っていたこと。 これは珍しい構成ではないので、独立した工程として書いておく。
| 系統 | 読み込み時期 | 置き場所 |
|---|---|---|
| 本体 | 起動時に一度 | Content/Data/Localization/*.resx |
| コンテンツ | そのシナリオを開始した時 | Content/Scenarios/<名前>/Localization/<Context>.<lang>.sbl + .resx |
本体のテキストが.resxだったなら、Scenarios/や
Campaigns/以下も*.resxで再帰検索する。それだけで出てくる。
そして既存言語の枚数を数える——Space Engineersでは
9言語×15コンテキストが揃っている中で日本語だけ0枚だったので、
欠落が一目で分かった。「他の言語にあって日本語に無いファイル」は、
そのまま作業リストになる。
マニフェスト(.sbl)は<Language>要素の完全一致で
選ばれるので、jaとja-JPのどちらが正しいかを外すと
何も起きないまま静かに英語のままになる。
幸い、あるシナリオ1本にだけKeen自身が作った日本語ファイルが1枚だけ紛れ込んでいた
(Warfare2/Common.ja.sbl)。中を見れば
「ファイル名の接尾辞は.ja、<Language>はja-JP」と
正解がそのまま書いてある。
目的の言語のファイルが1枚でも残っていないか、全体を検索してから書式を決める。
この形式は言語ファイルを先に読み、最後に既定(英語)を上書き禁止で重ねる。 つまり日本語ファイルにキーを書かなければ自動的に英語になるので、 訳していないキーを英語で埋める必要はないし、埋めてはいけない。 フォールバックの向きを先に確認すると、「未訳をどう表現するか」が決まる—— 埋める設計なら全キー必須、重ねる設計なら書いた分だけでよい。
同じゲームの中でも、ローカライズ対応のシナリオと、テキストを
スクリプトに直書きしたシナリオが混在していた。後者は書き換えるしかないが、
同じ場所に「表示する文字列」と「オブジェクトを引くための識別子」が混在しているのが
危険なところ。Space EngineersのFrostbiteでは、同じ
<ConstantScriptNode><Value>に
目標文とFrostbite Bombing Drone Mk2(出現させるプレハブ名)が並んでいた。
後者を訳すとシナリオが静かに壊れる。
ビジュアルスクリプトはノードのグラフなので、定数ノードの出力先ノードを辿って、
呼び出し先が表示系の関数かどうかで機械的に判定できる
(AddQuestlogDetail/ShowNotification等の
許可リストに全ての流れ先が入っている場合だけ訳す)。
これで25件が「訳してよい」、7件が「触ってはいけない」と自動で分かれた。
さらに裏取りとして「その英文がコンテンツ全体で他に出てこないか」を全文検索する。 ワールドデータや別スクリプトに同じ文字列があれば、それは識別子として 使われている可能性が高い。今回は25件すべて、 ヒットしたのは自分が書き換えた対象と、PC版が読まないコンソール用の複製だけだった。
構造を持つファイルへの文字列置換は、置換対象を間違えても見た目には分からない。 書き出したファイルを originalと並べて再パースし、 要素の個数が同じで、値の差分が翻訳表と1対1に対応することを毎回検査する。 前半「実機の代わりにオフライン検査を置く」の「触っていないものの検査」を、 そのままここに適用する。
Frostbiteだけ直したつもりで配布した後、スイープをやり直すと
関数ノードに直接打ち込まれた引数(<MyParameterValue><ParameterName>message…)
という第二の置き場所が見つかった。Never Surrenderの目標見出し、Dead Drop Arenaの
クエストログ(残りリスポーン数)、Scrap RaceのHUDボード(残り時間)が全部これだった。
ただし大半は罠で、Learning to Surviveだけで100件近い英文がヒットしたが、
ほぼ全部が入力が別ノードに配線されていて使われない「引数の既定値」だった
("Recharge your oxygen and ydrogen"のように綴り誤りまである仮文)。
ビジュアルスクリプトは配線がある引数では既定値を無視するので、
「同じ引数名がInputParameterIDs/OriginNameに無い」ものだけが実際に表示される。
これで対象は約100件→2件に絞れた。スイープは「値の置き場所を全種類」と「配線の有無」まで見る。
さらに単語1つの見出し("Player"/"Rank")は
「2単語以上の英文」という抽出条件をすり抜けた。
表示系関数の本文引数なら、単語1つでも対象に含める。
数字に連結される単位(" min")も同様で、ここを落とすと
「建造フェーズ開始まで 4 min」のような混ざり方になる。
Frostbiteの"Deactivate lockdown"は、クエストログの表示と、
一度だけ実行する処理の判定キー(OnceWithKey→StoreBool、つまりセーブに残る)の
両方に配線されていた。値をそのまま訳すと、途中のセーブで判定がずれる。
日本語の複製ノードを新しいIDで足し、表示側の入力だけをそちらへ付け替え、
キー側は英語のまま残した。検査は「元のノードは全部残っている」
「増えたノードは翻訳表の値を持ち、出力先が全部表示系」の2点。
配布後、プレイ中の人から「左上の進行表示がまだ英語」とスクリーンショット付きで報告された。 翻訳漏れを疑ったが、その文面は原文も訳も揃っていて、自分の環境では日本語で出る。 真因はセーブデータが言語の状態を2か所で凍結していたことだった。 報告者は旧版(シナリオ未対応)で遊び始め、新版に更新して続きから遊んでいた。
| 凍結されるもの | 仕組み | 対処 |
|---|---|---|
| 読む言語ファイルの一覧 | シナリオ開始時に存在した.sblのパスを列挙してセーブに保存し、
ロード時はその一覧だけを読む。後から足した日本語ファイルは無視される |
ゲーム側で補う。読み込み関数の先頭に「英語ファイルの隣に日本語ファイルがあれば一覧に足す」を ILで注入。セーブを書き換えないので、クラウドセーブや英語版ホストのマルチにも効く |
| 表示中のクエストログの文面 | キーではなく解決済みの文字列(見出し・各行)を保存し、ロード時にそのまま復元する | インストーラーが「英文→訳文」の対応表で既存セーブを置換。 ゲームが実際に表示する条件(表示中かつ行あり)のセーブだけに絞り、元ファイルは退避。 アンインストールでは戻さない(進行を巻き戻さないため) |
自分の実機確認はいつも新規開始だったので、どちらの罠も一度も踏まなかった。 利用者は必ず既存のセーブで続きを遊ぶ。 再現は、翻訳ファイルを一時的に外して新規開始 → その場でセーブ → 翻訳を戻して修正を当てる → 続きから読み込むの順でやる。これで「修正前は英語、修正後は日本語」を両方この目で確かめられる。 さらにアンインストール後に「日本語ファイルを登録済みのセーブ」を製品版で読めるかも1回確かめる (存在しないファイルを指していても落ちないことを確認した)。
置換の対象を探す過程で、Keen自身が配布しているワールドの雛形
(サンドボックスの各開始地点・クイックスタート・一部シナリオのミッション)の
Sandbox.sbcにも、作者の環境で表示されていた英語のクエストログが保存されたまま
同梱されていると分かった。ほとんどは非表示状態で、表示状態のものも
開始直後にスクリプトがローカライズ済みの見出しで上書きするので実害は無かったが、
「新規で始めたのに、なぜか見覚えのない英文がセーブに入っている」の正体はこれ。
Windows向けにインストーラーを同梱するなら、.bat単体ではなく
.bat(起動用の薄いラッパー)+実処理スクリプトの2枚構成にする。
実処理側はPowerShell(UTF-8 BOM付き)でもPythonでもよい。
cmd.exeはバッチファイルを解析時のコードページで読む。ファイル冒頭で chcp 65001 を
実行しても、それは「これから読む行」には効くが、すでに字面が壊れて解釈された行は直らない。
日本語のファイル名や日本語メッセージをバッチに直接書くと、環境(特に既定コードページが
Shift_JISやCP1252のPC)によって化ける・行が分断されて別コマンドとして実行される、という事故が起きる。
PowerShellスクリプトはUTF-8 BOM付きで保存すればこの問題を回避できる
(Windows PowerShell 5.1はBOM無しファイルを既定でANSI=ロケール依存コードページとして読むため、
BOMは省略しないこと。Deadpoolでは実際にBOMを付け忘れて日本語が化けた末にパースエラーになった)。
.bat側は実処理を1行叩くだけの純ASCIIに留める。
| やること | 方法 |
|---|---|
| ゲームフォルダの自動検出 | ①コマンドライン引数(ドラッグ&ドロップ対応) →②パッケージの親フォルダ(ゲームフォルダ内に置かれた場合) →③Steamのlibraryfolders.vdf(steamapps\とconfig\の両方に存在しうる)を読み、登録済み全ライブラリを横断検索。レジストリのSteamPathから辿ると別ドライブのライブラリも拾える |
| 導入前の状態判定は3値にする | 製品版 / 導入済み / 見覚えのないものをMD5で区別する。3番目は1バイトも書かずに中止(他のMODを上書きして復元不能にしないため)。「サイズが違うか」だけの判定は不十分で、原版のサイズは一様とは限らない |
| 検証を全部済ませてから書き込む | 1ファイルずつ「確認して書く」を繰り返すと、途中で中止した時に半分だけ適用された状態が残る。全ファイルの判定を先に済ませ、問題が無い時だけ書き込みに入る |
| ファイルコピーは可能な限り追加方式に | フォーマットが許すなら(Lucius IIIのように)既存ファイルは一切変更せず新規ファイルの追加だけで完結させる。バックアップ・復元の実装自体が不要になり、アンインストールは削除するだけになる |
| それでも上書きが要る場合 | 初回実行時に必ずオリジナルを退避してから書き換える。バックアップが既にあるなら絶対に上書きしない(2回目の実行でパッチ済みファイルを「原本」として保存してしまう事故を防ぐ) |
| チェックサム表は手で書かない | 同梱物と検査値が食い違うと、正しいパッケージが「見覚えのないもの」として拒否される。パッケージ生成時に実物から算出して埋め込む |
| 再生成スクリプトのパス依存を排除する | 作業フォルダを移動した瞬間に動かなくなるので、スクリプト内の絶対パスは__file__基準に直しておく。出力先は引数で上書きできるようにする |
| 完成後に往復テストする | 配布物を展開したところから実行する。ゲームを製品版に戻す→導入→起動確認→2回目実行(「導入済み」と出るか)→復元(ハッシュが原本と一致するか)→改変ファイルを置いて拒否されるか、まで通す |
パッチを「ビルドスクリプト+素材」で配ると、実行環境にPython・画像ライブラリ・ 解析ツール一式が必要になり再現性が落ちる。完成したファイルをそのまま同梱し、 インストーラーはコピーと設定書き換えだけを行う形にすると、依存が消えて安定する (Homefrontは56MB、Deadpoolは60MB/zipで23MBの完成品を同梱した)。 ゲームを再インストールしても、その配布物を実行するだけで元に戻せる状態がゴール。
後続オブジェクトが持つ絶対オフセットが全部無効になり、起動1秒でアクセス違反。 同じ操作でもファイル内の位置で結果が変わる。詳細は「オフセットを持つ形式を安全に書き換える」。
ランチャー(Steam)経由でないと正常なビルドでも 0xC0000005 で落ちるゲームだった。 障害オフセットまで採取したが全部無意味。新しい計測手段は、まず既知の正常ビルドで対照を取る。
字幕フォントのテクスチャがバルクデータフラグ「未使用」・サイズ-1で、
ピクセルが1バイトも無かった。本物は別パッケージにシークフリーで焼き込まれていた。
「データが無い」ように見えたら、同名オブジェクトの実体を全パッケージで探す。
出荷データを読んで書き戻すとバイト完全一致、値も全部範囲内、構造検査も通過—— それでも起動しなかった。往復一致が保証するのは書式だけで、配置やエンジン側の前提は 別問題。形式を確認したら次は置き場所を疑う。
if japanese: で判定していたため、意図的に空にすることができなかった。
分割されたプロンプト(Key1 + <ボタン> + Key2)を日本語の語順に直すには
前半を空にする必要がある。キーが載っていること自体を翻訳済みの印にする。
フロントエンドとゲーム中で同じフォントの別コピーが使われており、 片方だけ直したのでメニューは完璧・ゲーム中は全部豆腐、という症状になった。 全パッケージをクラス名で走査して、該当する物を全部直す。
上記「配布パッケージの型」の通り。バッチはASCIIのみにし、メッセージはPowerShell側
(UTF-8 BOM付き)か、別ファイルに置いてtypeで出す。
原版ファイルのサイズは一様とは限らない(あるフォントだけ原版が極端に小さい、等)。 サイズではなく中身の特徴(例: 原版はある文字種を1文字も含まない)かハッシュで判定する。
ボタンや設定値表示など、幅が固定の箇所は英語なら収まっても日本語では溢れる。 フォントを拡大した時も同じことが起きる(Homefrontでは字幕に合わせて3倍にしたら メニューが重なって読めなくなった)。実機のスクリーンショットで各画面を最低1回は見る。
数百〜数千行のうち数行だけ、想定外の理由(空セル、余分な区切り文字の混入など)で 列がずれることがある。件数が一致しても中身がずれている可能性を疑い、抽出直後に 境界チェック(欠番検出、フィールド数の分布確認)をルーチン化する。
日本語は単語間に空白が無く、独自の折り返しロジックだと詰まることがある。 実機で毎回確認するのは高コスト。既に中国語・韓国語などCJK圏の公式ローカライズが 同梱されているなら、そちらのデータ(改行タグの有無や長文の入り方)を先に覗くのが最速の判断材料。
特にUnityのシリアライズ済みファイルで顕著(遅延読み込み中のバッファが書き込み後の内容を
参照してしまう)。読み込みと書き込みは必ず別ファイルにし、os.replace()等で
アトミックに置き換える。置換がPermissionErrorで失敗する場合は、書き込み側の
プロセスがハンドルを保持したままの可能性を疑う。
オブジェクトごとのオフセット・サイズをヘッダで管理する形式(Unity SerializedFile、 UE3パッケージ等)に対して、長さが変わる書き換えを素朴に行うとファイル全体が壊れる。 ライブラリの保存APIに再計算を任せるか、そもそもサイズが変わらない編集方法を探す (Homefrontの字幕は「配列内の2要素を入れ替える」=合計サイズ不変にできたので、 オフセット修正が一切不要になった)。どうしても大きくなるなら末尾に逃がす。
「カテゴリ名の後に8言語の文字列が並ぶ」のように最初の数件だけで結論づけると、 実際にはもう1フィールド(検索キーなど)が後ろに隠れていて、境界がずれたまま 「それらしい件数」がパースできてしまうことがある。パース終了位置がオブジェクト境界・ ファイル末尾とちょうど一致するかを必ず最終検証にする。
「表示用の訳文」のつもりで、実際には「原文を引くための検索キー」フィールドを 書き換えてしまうと、字幕システムがヒットしなくなり "MISSING TRANSLATION" のような表示になる。 修正後は必ず、検索キー側が変更前の原文と一致することを機械的に検証してから実機テストに進む。
平文で見つかった字幕ファイルや同名の別アセットが、実際にはゲームから参照されない
残骸ということがある。1件だけ書き換えて実機で反映されるか確認するまで着手しない。
HomefrontでもLocalization/INT/UI_Subtitles.int といういかにも本命な平文ファイルが
あったが、実際の字幕は音声アセット側に埋め込まれた方が使われていた
(ただしこのファイルは訳文の対応表としては正確で、別の意味で役に立った)。
メニューだけを見て「変化なし」と判定し続けたが、同じ変更でムービー字幕だけは 変わっていた。テキストの面ごとに供給元が違うので、1回の起動で メニュー・ムービー字幕・会話字幕・目標表示を全部確認するチェックリストを作る。
.upkだけ見て.mapを見落とし、さらに完全圧縮されたパッケージは
ヘッダが読めず走査から漏れていた。Deadpoolでもクラス名の生バイト検索で
圧縮パッケージを取りこぼした。「該当クラスのオブジェクトはゲーム全体で何個か」を数え、
既知の内訳と合計が一致するかで漏れを検出できる。
絶対パスをスクリプトに直書きしていたため、フォルダ移動で即死する状態だった。
__file__基準に直してから移動し、移動先で実際に再生成して同一の成果物が
できることを確認した。移動する前に直す。
シェルツール経由でPythonにファイル内容を出力させたところ、正しく保存したはずの
日本語が�だらけの文字化けとして返ってきて、一瞬「翻訳エージェントがデータを
破壊した」と誤認した。ord()で1文字ずつコードポイントを直接検査すると
実データは完全に正しいUTF-8で、化けていたのはこの作業環境のターミナル出力
パイプ側の表示だけだった。「目で見た文字化け」は実データの破損とターミナル側の
表示崩れの2通りがあり、byteやcodepointレベルの機械的な検査でしか区別できない。
先に「壊れた」と決めつけて復旧作業に走らなかったのは、たまたま違和感(該当行が
翻訳対象外のはずの既訳済み行だった)に気付けたから。
SendKeysを送り続けて反応なしと誤認したSerious Sam 2実機のメニュー操作を自動化しようとしてSendKeysで矢印キー・Enterを送ったが
何も起きず、最初は「フォーカスが取れていない」と誤診断した。実際はゲーム側が
DirectInput/RawInputで直接ポーリングしており、メッセージキュー経由のSendKeysは
そもそも構造的に届かない。Win32 SendInputにスキャンコードで送るよう
切り替えると即座に解決した。同じアプリでも「標準ダイアログ」と「ゲーム本体の
入力ループ」で受け付ける入力経路が別、と知っておくと切り分けが速い。
台詞のマーカー(<a3>)がコロン直後にある行と、空白を挟む行の
両方があったのに、正規表現が前者しか想定していなかった。結果、後者ではマーカーが
キーの先頭に残り、永久に一致しなくなった。症状は「英語のまま表示」だけで、
未着手と全く区別がつかない。詳細は「翻訳作業を速く安全に回す型」。
抽出側で"や'を復元していなかったため、
実際には翻訳済みの行が大量に「未翻訳」として検出された。
存在しない問題を追いかけて時間を溶かすタイプの事故。
突き合わせ処理を書いたら、まずヒット率が想定通りかを確認する
(半分も一致しないなら、たいてい正規化漏れ)。
「行頭が話者名:」しか拾わない正規表現のため、
(if: …) [Hans: …]形式の分岐内台詞が全滅。症状は
「NPCに2回目に話しかけると必ず英語」という、一見ランダムな取りこぼしだった。
「カバー率100%」は分母が正しい時しか意味を持たない——
抽出器を直したら母数が5,744→6,607に増えた。
OnGUIで描いた訳文はデスクトップのウィンドウにしか出ない。
元の英語は消してあるので、ゴーグルの中は英語も日本語も無い空欄になる。
非VRのスクリーンショットでは完璧に見えるため、VRゲームを非VRだけで検証していると
絶対に気づけない。詳細は「実行時フック方式」。
ビルドは成功、なのに実行時に無関係なパッチが失敗する——という症状を
「フックを増やしたせいでインタープ層が壊れた」と2回誤診断した。
実際はbin/Release/net6.0/に残っていた古い試作DLLをコピーしていただけ。
エラーがソースに存在しない関数を指していたのが唯一の手がかりだった。
配置はスクリプト化し、ビルド系に出力先を問い合わせ・古い出力は拒否・コピー後にハッシュ照合まで自動化する。
MelonLoaderはLogs/をファイル名順で整理するが、日付がゼロ埋めされていないため
26-9-11_…は26-9-4_…より「古い」と判定される。
利用者のVRプレイ直後に自分が1回起動しただけで、その回のログが削除された。
利用者がプレイした後は、何か起動する前に診断ログを別名で退避する
(MOD側でも、初期化時に前回分を*.prev.logへ複製しておくと安全)。
Yes/Noボタンが空欄で表示され、当然「MODが消している」と考えた。実際は
ゲーム本体が空文字列を渡していた(連結文字列の先頭が||=
回答ラベルが両方空、と判明)。翻訳では出力されていない文字を復元することはできない。
直す前に、その文字列が本当に自分のフックを通っているかログで確認する。
1つの要素だけ「alphaが0でも実は見えている」ことに気づき、
その判定を全要素に適用したところ、非表示のはずのパネルが何十枚も同時に
描画された。例外は必ず対象を限定して入れる(親オブジェクト名で絞る等)。
1件の不具合を直す変更が、他の全部に効いてしまう形になっていないか毎回確認する。
パッチ済みDLLをリフレクションで読むと正しいのに、ゲームだけ旧挙動。
*.ni.dllがC:\Windows\assembly\NativeImages_*から読まれていた。
MVIDを変えればネイティブイメージが拒否される。詳細は「.NET / IL解析が必要な時」。
先に手動でコピーしてからバックアップ付きデプロイを初回実行したので、 原本フォルダにパッチ済みDLLが入った。配布物のチェックサム表まで汚染され、 往復テストで「復元したのに元に戻らない」で初めて気づいた。 バックアップは「原本のハッシュと一致するか」まで検査する。 そしてパッチが新規に追加するファイルは、退避対象から明示的に除外する (そうしないと自分の生成物を原本として保存してしまう)。
フォント拡張スクリプトがゲームフォルダの.xmlを読んでいたため、
2回目の実行で「既に足したページ」の続きにもう1枚足し、
実在しない-5.ddsを参照する.xmlができあがった。
ビルドの入力は必ず退避した出荷ファイルにする——
「何度実行しても同じ結果になるか」をビルドの設計条件として先に決める。
本体の resx を全部訳し、実機確認も配布物も作り終えた後で、 依頼者から「シナリオ上の進行表示だけ英語のまま」と指摘された。 シナリオは各フォルダに独立したローカライズ一式を持っていて、 そこだけ日本語ファイルが無かった。本体の翻訳が終わった時点で、 同じ形式のファイルがコンテンツフォルダにも無いか再帰検索するのを工程に入れる。
スクリプト直書きのシナリオでは、同じ種類のノードに目標文と 出現させるプレハブ名が並んでいた。長さや見た目では区別できない。 出力先の関数が表示系かどうかでグラフを辿って判定するのが唯一確実な方法。
同梱の公式日本語resxは、翻訳してはいけない制御ID(JUMP等)を
日本語に訳していたり、使っている文字のうち269字ぶんのグリフがフォントに無い
(=出荷状態で豆腐になる)という状態だった。
「公式だから正しい」ではなく、自分の検査を公式データにも同じようにかける。
置換トークン照合と文字集合チェックが、そのまま公式側のバグ検出器になる。
報告のスクリーンショットの文面は、訳文ファイルにちゃんと入っていた。 原因はパッチ前に始めたセーブが「読む言語ファイルの一覧」と「表示中のクエストログの文字列」を 保存していたこと(前半「セーブが翻訳を凍結する」)。 報告の文面をまず訳文ファイルで検索する——入っていれば、原因は翻訳ではなく読み込み側。
関数ノードの引数に直接書かれた英文を丸ごと見落とし、さらに「2単語以上」という抽出条件で
"Player"のような見出しも落としていた。逆に、配線済みで使われない既定値を
数えると100件近い偽陽性になる。置き場所を全種類・配線の有無・単語1つの3点を条件に入れる。
ConvertFrom-Json がキーの大文字小文字を区別しなかったSpace Engineers「英文→訳文」のJSONオブジェクトに"CONTROL TOWER"と"Control Tower"が
同居していて、PS 5.1 が重複キーとして例外を投げ、インストーラーが途中で止まった。
インストーラーが読むJSONは [{"en":…,"ja":…}] のペア配列にする。
あわせて、パッチ本体の書き込み後に走る「おまけ」の処理は丸ごと try/catch で囲い、
失敗しても警告で済ませる(本体の導入まで失敗扱いにしない)。
インストーラーの検証をやり直すためにバックアップフォルダを消したら、 前回のテストで書き換えたセーブの元ファイルも一緒に消えた(変更は見出し1行だけ)。 ゲーム自身の Backup フォルダの最新コピーとバイト一致するところまで戻して事なきを得た。 ユーザーのデータに触れるテストをしたら、やり直す前に元へ戻す。
公式日本語は無く、6,059行を全訳した。テキストは全部Coalesced.intという
1ファイルに入っていて、XOR難読化さえ解けば抽出も書き戻しも素直。
時間を食ったのは翻訳でもフォント生成でもなく、パッケージの書き戻し方だった
(3回起動不能にした)。フォントは3系統がバラバラに存在し、
それぞれ別の理由で詰まった。
| 表示面 | 描画系統 | 実体の置き場所 |
|---|---|---|
| メニュー・ロード画面 | Scaleform GFX | Startup_int.xxx(9フォント) |
| フロントエンドのUI部品 | Scaleform GFX | UI_GFxCLIK_p.xxx の CLIKComponents_GFX |
| ゲーム中のHUD・吹き出し | Scaleform GFX | TransGame.xxx の別コピー(同名の CLIKComponents_GFX と OnlineNotify_GFX) |
| ゲーム中のセリフ字幕 | UCanvas(Scaleformではない) | Startup_int.xxx の SubtitleFont + DXT5アトラス |
フロントエンドとゲーム中で同じフォントの別コピーが使われているのが最大の罠。
UI_GFxCLIK_p.xxxだけ直すとメニューは完璧になり、ゲーム中だけ全部豆腐になる。
全1,787パッケージを開いてクラス名で走査すると、実グリフとレイアウトを持つ
DefineFont3は全部で12個しかなかった。
TransGame/Localization/Cooked/PC/ にある1ファイルに、
30個のローカライズファイルが丸ごと入っている。
構造は件数 → (名前, 中身) の繰り返しという単純なもので、
文字列は長さの符号でエンコードが切り替わる。
鍵は61文字の文字列で、実行ファイルの.rdataにUTF-16でそのまま入っていた。
統計で復元した鍵と一致したので、解析が正しいことの確認にもなった。
そして負長=UTF-16で書けば日本語コードポイントがそのまま通る——
言語スロットを増やす必要も、エンコード変換も要らない。
| 事項 | 内容 |
|---|---|
| PKG_StoreCompressed | 非圧縮で書き出すならPackageFlagsから0x02000000を落とす。残したままだと起動しない |
| エクスポート表 | 基本45バイトだが、+44のマーカーが1なら更に24バイト続く可変長。順に歩かないとずれる |
| ByteProperty | このビルドは列挙名タグを持たない。8バイトの値そのものが列挙エントリ名。タグを持つのはStructPropertyだけ |
| バルクヘッダ | int32が5つ(標準UE3の4つ=flags/count/size/offsetの後にもう1つ)。同一テクスチャの2コピーで同値なのでテクスチャ固有のメタデータ。そのまま持ち越す |
| BulkDataOffsetInFile | インラインデータではエンジンが参照していない(再ビルドすると出荷テクスチャの分も全部陳腐化するが正常動作する)。直そうとしない方がよい |
| 圧縮 | LZO1X。128KBブロック。展開だけ実装すれば足りる(書き戻しは非圧縮でよい) |
自作エンコーダで出荷フォントを書き戻し、バイト差分を取って2つのバグを見つけた。 推測では絶対に当たらない値だった。
0x8C = HasLayout | WideOffsets | WideCodes。WideOffsets/WideCodesは強制的に立てる(出荷フォントに立っていないものがある)FillStyle0=1は最初の輪郭にだけ立てる
ゲーム中のセリフ字幕は Scaleform ではなく UCanvas が描いている。
UI_TransEngineFonts_p.xxxにあるのはピクセルを持たない抜け殻で、
本物はStartup_int.xxxにシークフリーで焼き込まれていた(371文字+512×512 DXT5)。
出荷時のCharRemapはコード0〜8482しか持たないので、
日本語が透明になり?と!だけ残るという症状になる。
配置の規則は実測で決まる。矩形は Y + VerticalOffset に描かれ、
ベースラインは行頭から24px(. A B のインク下端が揃う行)。
USizeは送り幅であってインク幅ではない——ここを間違えると字が隣とくっつく。
アトラスは1024×2048に拡張し、出荷ページをDXT5ブロック単位で左上にコピーしてから
日本語1,496字を下に詰めた。出荷ピクセルは1バイトも再エンコードしていない。
| もの | 用途 |
|---|---|
| coalesced.py | Coalesced コンテナの pack/unpack(両ファイルでバイト一致の往復を確認済み) |
| upk.py | UE3パッケージの読み書き。save_preserving()が本命——他のエクスポートを動かさない書き出し |
| lzo.py | LZO1X展開(純Python)。参考実装のgoto制御フローを忠実に写経したもの。素朴に移植すると必ず壊れる |
| gfx.py / swffont.py / mkfont.py | Scaleform GFXムービー、DefineFont3の解析と、既存フォントへのグリフ追記 |
| ue3font.py / mkcanvasfont.py | UE3プロパティリスト・Texture2D・DXT5・Canvasフォントのアトラス拡張 |
| translate.py | 対訳オーバーレイの抽出/トークン検証/文字集合/ビルド |
| verify.py | 実機の代わりのオフライン検査(前述の4項目) |
| boot-test.ps1 | Steam経由で起動して生存確認。直接起動は不可 |
| package.py | 配布zipの生成(完成品+PowerShellインストーラ+チェックサム表) |
翻訳作業はゼロだった。家庭用機版の公式日本語データがPC版にも丸ごと同梱されており、 メニューも字幕も「表示させる」だけの仕事だった。時間を食ったのは ①どのフォントが実際に使われているかの特定(総当たりの破壊テストで解決)と ②日本語グリフを持つフォントの自作(PC版には日本語フォントだけが入っていない)。
| 表示面 | テキストの供給元 | フォントの決まり方 |
|---|---|---|
| メニュー・目標表示 | Coalesced_int.bin | UIスキン(PC_Skins.upk)のスタイル経由 |
| ムービー字幕 | Coalesced_int.bin | GCEngine.ini の SubtitleFontName で明示指定 |
| 会話字幕 | 各音声アセットの LocalizedSubtitles | 上記とは別系統 |
| 収集アイテム(新聞) | Coalesced_int.bin | ラベルが Font プロパティで直接指定 |
GCEngine.ini(ドキュメント\My Games\HOMEFRONT\GCGame\Config\)の
Language=jpn は設定しても無視される(PC版は日本語が未対応言語)。
そこで既定言語 int の中身そのものを日本語に差し替える方式にした。
Coalesced_jpn.bin を Coalesced_int.bin として配置するだけでは足りず、
中に埋め込まれたファイルパス文字列(...\Localization\JPN\...)も
INT に書き換える必要がある(そうしないと参照が解決できず、
画面に ?int?ui_frontend.MainMenu.ResumeGame? のような生キーが出る)。
ゲーム全体でFontオブジェクトを持つのは17パッケージ・150個。そのうち
EngineFonts.upk / UI_Fonts.upk / PC_UI_Fonts.upk は
ランダムバイトで丸ごと破壊しても平然と起動する=一切読まれていない。
実際に描画に使われていたのは IpDrv_LOC_int.upk(IpDrv は
本来ネットワーク用パッケージの名前)で、ここを無効化すると
メニュー・ムービー字幕・会話字幕の全てが同時に消えた。
名前からは絶対に推測できないので、破壊テスト以外に特定手段が無かった。
UE3のクック済みビルドでは、必要なオブジェクトがあちこちのパッケージに複製される。
同名のFontが13個存在し、PC_UI_Fonts.TradGothicBoldCondensed という参照パスは
パッケージ名を含むのに、その名前のファイルは読まれていない
(複製先のパッケージ内に同名のPackageオブジェクトがあり、そちらで解決される)。
参照パスからファイルを推測しないこと。
まずGildorの decompress.exe で解凍する(配布状態は圧縮されており、
UE Explorer等は「compressed packages are not supported」で開けない。umodelは透過的に
解凍して読むので気づきにくい)。以下は解凍後のエクスポートデータの構造。
None に解決されたら終わりArrayPropertyに要素型の情報が埋まっていないのがこのバージョンの特徴で、 要素の型はクラス定義側を知らないと分からない(UE ExplorerがFontの配列を 読めないのもこれが理由)。逆に言えば、型さえ分かれば手書きパーサーで十分扱える。
Engine.Font)の中身
各 SoundNodeWave の LocalizedSubtitles に、
英・日・独・仏・伊・露・波・チェコ・西・蘭の10言語が最初から格納されていた。
しかも各要素にLanguageExt(言語名)が無く、純粋に配列の並び順で選択している。
つまり日本語(添字1)を先頭(添字0)と入れ替えるだけで日本語字幕になる。
2要素の入れ替えなので配列全体のサイズが変わらず、オフセット修正が完全に不要だった。
Subtitles / bMature / bManualWordWrap)→ None で終端Text:StrProperty + Time:FloatProperty → None
対象は Maps\SP\<ミッション>\*_LOC_int.upk の261パッケージ。
うち81パッケージ・1764件に日本語が存在した(残りは効果音のみで字幕を持たない)。
入れ替え対象は「添字1」と決め打ちせず実際に日本語文字を含む要素を正規表現で探して
先頭と交換する実装にした(既に適用済みのファイルを二度処理しても安全になる)。
.iniファイルとローカライズファイルをまとめて格納したもの。構造は単純で、 パース→編集→再構築が問題なくできる。無編集で往復して元ファイルとバイト単位で一致することを 確認してから編集に入ると安全(文字列ごとにASCII/UTF-16が混在しているので、 元のエンコーディングを保持して書き戻す必要がある)。
..\GCGame\Localization\JPN\UI_MenuText.jpn のような相対パス[U_MainMenuScreen] の中身公式の日本語データにキーそのものが存在しないケースもあった (PC版限定の「NEWS」項目は家庭用機版に無いため)。この構造が書けるようになっていれば、 キーを1つ追加するだけで解決できる。
| 道具 | 用途 | 注意点 |
|---|---|---|
| umodel (UE Viewer) | エクスポート一覧・テクスチャ書き出し | 圧縮パッケージを透過的に読む。-list -path=<dir> "*" で全パッケージを一括列挙できる(パッケージ名は独立した行として出力される) |
| decompress.exe | パッケージの解凍 | Gildor製。ダウンロードにはRefererヘッダが要る(無いと404のHTMLが降ってくる) |
| UE Explorer / UELib | プロパティの答え合わせ | 解凍済みでないと開けない。Font配列は読めないが、他は自作パーサーの検証に使える |
| 自作Pythonパーサー | 実際の書き換え全般 | ヘッダ・名前表・インポート表・タグ付きプロパティ・Font・字幕・Coalesced |
新規解析ではなく、2007年に有志が作った非公式日本語化MODを、20年後の現行Steam版 (コミュニティパッチ込み)に移植・拡張する仕事だった。旧MODは字幕の大部分を 訳していたが、フォルダ名変更・新機能追加で現行版にそのまま置けず、しかもフォントの 文字カバー範囲が足りずに常用漢字が化けるバグを長年抱えていた。 山場は解析ではなく、①現行版で何が変わったかの差分特定と ②同梱エディタでのフォント再生成だった。
拡張子.groの独自アーカイブはPK\x03\x04で始まるただのZIP。
優先順位はファイル名のアルファベット順(後勝ち)で、パッチファイル名が採番規則に
沿っていたのでファイル名ソート=適用順のまま扱えた。ローカライズは
Content/<Game>/Locales/<言語名>/以下に、上書きしたい元のパスを
まるごと再現した相対パスで同名ファイルを置くだけで成立する仕組み
(str_strCurrentLocaleを言語名に設定すると読み込まれる)。
2007年の実装がこの重ね置き方式のまま現行版でも通用するかは、
現行パッチに同梱されていたドイツ語ロケールの実例で確認できた
(公式が同じ仕組みで最近ドイツ語を追加していた)。
2005年オリジナル版と現行パッチ後の英語原文を全ファイルdiffしたところ、 2,700行超あるゲーム内テキストのうち実質的な文面変更はスペルミス修正2件だけで、 残りは改行コードの正規化のみだった。つまり旧訳の9割以上はそのまま現行パスに 置き直すだけで再利用できると判断でき、机上の勘ではなく実際のdiff結果を根拠にできた。 一方でフォルダ名変更(1ステージ)・新規追加コンテンツ(マルチプレイ専用ステージ、 新設の図鑑機能)は旧訳に対応が無いため新規翻訳が必要と機械的に切り分けられた。 「移植」案件では、真っ先に新旧の原文差分を取ってから作業量を見積もる。
新設の図鑑(敵・武器・NPCの解説文)は英語原文がゲーム内のどこにも見当たらなかった (実行ファイルに直接ハードコードされているらしく、外部ファイルとして取得不能)。 唯一存在したのが前述のドイツ語ロケールで、これが実質的な原文の代わりになった。 ドイツ語からの翻訳になるため精度は英語起点より落ちるが、「原文が存在しない 新規コンテンツを完全放置する」より「別言語からでも翻訳して埋める」方が “完全日本語化”の要求には応える、という判断をした。
同梱のSeriousEditor2.exeにビットマップフォント生成機能があったため、
形式のバイナリ解析は一切不要だった。手順・UTF-16の罠・アトラス容量の罠は
前半の「フォントを用意する」節に一般化して書いた通り。9種類のフォント
(通常/HD/メニュー用/サーバー一覧用/コンソール用など用途別)を同一の文字集合・
同一ツールで作り分けた。
旧訳のテーブルファイル(key=value形式、UTF-8 BOM付き)は一見ほぼ翻訳済みに見えたが、 値に日本語文字を1文字も含まない行を機械的に抽出したところ、 3ファイル合計で1,500行超が未翻訳のまま放置されていることが分かった (メニュー項目・HUDメッセージ・アイテム取得通知など)。目視や「だいたい訳してある」 という印象では気付けない規模。「翻訳済みらしきファイル」でも、 未翻訳行の自動検出を1回はかけてから完了と判断する。
| もの | 用途 |
|---|---|
| zipfile / unzip | .groアーカイブ群の読み取りと優先順位マージ(自作パーサー不要) |
| SeriousEditor2.exe(同梱) | 日本語ビットマップフォントの生成。GUI自動操作で運用 |
| 差分スクリプト | 2005年オリジナル版と現行パッチ後版の全文字列ファイルdiff。再翻訳が要る範囲の特定 |
| 未翻訳検出スクリプト | key=value形式ファイルから「値に日本語を含まない行」を正規表現で抽出 |
| SendInput / SendKeys 併用スクリプト | ゲーム本体とエディタそれぞれに合わせた実機・ツールのGUI自動操作 |
VR用メカゲーム。7作の中で唯一、ゲームのデータを1バイトも書き換えていない。 Unity IL2CPPビルドでC#の改変ができず、テキストはシーンオブジェクトに散在し、 フォントに日本語グリフが無い——という三重苦だったため、 MelonLoader + HarmonyでUIの文字列設定に割り込み、日本語は自前で画像化して重ねる 方式を採った。方式そのものの一般論は前半の「実行時フック方式」に書いた。 時間を食ったのは解析でもフォントでもなく、「訳したのに英語のまま出る」の原因究明だった。
| 表示面 | 割り込み先 | 備考 |
|---|---|---|
| ストーリー字幕 | SubtitleGooey.Display(name, text, duration) | 引数を書き換えて空にし、自前で描画。話者名も同時に来る |
| メニュー・HUD・訓練文言 (86画面すべて) | GooeyElement.SetSymbol(string) | アイコン名も同じ経路で来るので、辞書に無ければ素通しする |
| 上のうち、シーン復元で 初期値が入るもの | GooeyElement.RefreshSymbol() | セッターを通らないため別途必要。symbolNameフィールドを直接読む |
原文テキスト自体はvm_Data/StreamingAssets/Stories/*.html(Twine形式)に
平文で入っていたので、翻訳の抽出はデータから、表示の差し替えは実行時から、という
ハイブリッドになった。メニュー系の文言は.assets内に散っていたため、
長さプレフィックス付き文字列としてバイナリ走査して回収した。
これが本件の全て。どれも訳文は最初から完成しており、キーが違っていただけだった。 そして3つとも、実行時ログと辞書の突き合わせで判明した—— 静的解析や推測では1つも当てられなかった。
| 実際に渡ってきた文字列 | 影響 | 原因 |
|---|---|---|
| <a3>Keep honking, pilot. | 1,736件 | 抽出時にボイス収録マーカーがキーに残った。台本には
話者:<a3>と話者: <a3>の2形式が混在していて、
正規表現が後者を処理できていなかった。訳文の側にもマーカーが残っていた |
| BASIC|TRAINING | 約485件 | メニューの複数語ラベルは|が改行。空白で登録していたキーが全滅。
さらに抽出時のフィルタ正規表現が|を許可していなかったため、
そもそも候補として拾えていなかった |
| ||Do you wish to proceed… | 確認ダイアログ | Yes/No画面は回答1|回答2|質問文という1本の連結文字列。
先頭の||は回答ラベルが両方空という意味で、
画面の?ボタンはゲーム側の既定アイコン(=翻訳では直せない) |
| (if: …) [Hans: …] | 863件 | 抽出器が行頭の話者名:しか見ていなかったため、
条件分岐の中に書かれた台詞(NPCの2回目以降の会話・分岐後のミッション台詞・
対戦中の一言)が丸ごと未抽出。「2回目に話しかけると必ず英語」の正体 |
| press Left Stick Up, to … | 139テンプレート | 原文は{0} to move around the shipで、穴は実行時に入力機器ごとの
ボタン名で埋まる。Mod側に穴埋め照合が無く、139件が一度も一致していなかった
(非VRで動いて見えたのは、キーボード版の完成文字列を個別登録していたから) |
依頼者の初VR起動で翻訳が1文字も表示されないことが判明した。
OnGUIはデスクトップのウィンドウにしか描かれないので、
元の英語を消した結果、ゴーグルの中は空欄になっていた。
非VRのスクリーンショットでは完璧に見えており、それまでの検証では一度も踏まなかった。
対処は訳文のテクスチャを板にして、置き換えた要素の子として3D空間に置くこと。 一般化した手順・大きさと奥行きの決め方・切り分け方は前半の 「実行時フック方式」に書いた。この作品固有の値は次の通り。
| 項目 | 実測値・採用値 |
|---|---|
| 1行の高さ | Gooey.letterHeight = 0.25(静的。要素のscaleはTransformに既に入っている) |
| 揃え | GooeyElement.pivot が -1/0/+1。板の位置 = pivot × サイズ ÷ 2 |
| シェーダー | GUI/Text Shader(ZTest Always)。UI/DefaultとUnlit/Transparentはこのビルドに無い |
| 描画順 | GooeyMaterial(Sleek/Gooey/Main・queue 2500)の +1 = 2501 |
| 不透明度 | max(alpha, alphaAnimationValue) × color.a(非選択0.39/選択0.81を再現) |
| レイヤー | 要素ではなくGooey(描画器)のgameObject.layer。字幕要素はレイヤー2で、コックピットのカメラが描かない |
毎フレームの可視判定で不透明度0の要素を破棄する実装にしたところ、
メニュー項目が1つも表示されなくなった。実測すると、項目は生成直後は0で、
0.2秒かけて0.81まで上がる——MODの毎フレーム処理が、ゲームがフェードを進める
前に走っていたため、作った瞬間に自分で消していた。
旧方式(OnGUI)はフレーム末尾に走るのでたまたま成立していただけ。
破棄ではなく非表示にし、破棄は文字列設定のフックに任せる。
|はメニューラベルでは改行、確認ダイアログでは項目の区切りだった。
区切り文字の意味を1つだと決めつけない。そして
抽出のフィルタ正規表現が、ゲームが実際に使う記号を許可しているかを
必ず確認する(|を弾いていたせいで485件が最初から見えていなかった)。
依頼者から「AIキャラの口調が時々変わる」と指摘され、 話者ごとの台詞抽出 → 文末の丁寧語/常体の自動分類 → 多数派に少数派を寄せる という手順で224件を修正した(手法の一般化は前半に記載)。 タグ修正で復活した1,669件は、この監査を受けていなかったため、 キーの修正後に監査をやり直す必要があった—— 「表示されていなかったテキスト」は品質チェックからも漏れている、という当たり前だが 見落としやすい話。
| 項目 | 値 |
|---|---|
| 辞書エントリ | 7,876件(+テンプレート164件で機器依存の文に対応) |
| ストーリー台詞のカバー率 | 6,607 / 6,607(100%)※分岐内の台詞を含めた後の母数 |
| 実プレイでの字幕翻訳率 | 94.7% → 100%(未翻訳20種を特定して解消) |
| 口調の外れ値 | 224 → 8件(残りは構造上の制約と標本不足で意図的に据え置き) |
| 表示方式 | 3D空間に板を配置(VR・非VR両対応) |
| もの | 用途 |
|---|---|
| MelonLoader + Harmony | IL2CPPゲームへのMOD注入とメソッドフック |
| Core.cs(自作MOD) | 3つのフック+辞書/テンプレート照合+診断ログ出力 |
| WorldText.cs | 訳文テクスチャを3D空間の板として要素の子に配置(VR対応の本体) |
| deploy_mod.sh | ビルド→出力先をMSBuildに問い合わせ→古い出力は拒否→配置→ハッシュ照合。手作業のコピーを禁止するための工程 |
| menu_diag.log / missing.json | ゲーム自身に吐かせた「実際に表示された文字列」。原因究明はほぼこれだけで完結した |
| メタデータ検査ツール(自作C#) | System.Reflection.MetadataでIL2CPP生成DLLを読み、クラス・メソッドのシグネチャを一覧 |
| 抽出・監査スクリプト群 | Twine台本からの台詞抽出、辞書との突き合わせ、話者別の口調分類 |
公式の日本語が、テキストもフォントも丸ごと同梱されていた。
Content/Data/Localization/*.ja-JP.resx が約9,300件(92%訳済み)、
Content/Fonts/*/FontDataJP* が日本語専用のビットマップフォント——
コンソール版のために作られた資産が、PC版では言語として登録されていないだけで
まるごと死蔵されていた。作業は3つに分かれた:
①コードに日本語を登録する(IL 7命令)、
②未訳690件の補完、
③フォントに足りない306グリフの追加。
山場は解析でも翻訳でもなく、①のパッチが「効いているのに効いていない」に見えた原因究明だった。
設定ファイルで言語を日本語(enum値26)に強制すると、起動時に
KeyNotFoundException at MyLanguage.LoadLanguage で落ちた。
VRage.dll の MyTexts 静的コンストラクタを読むと、
AddLanguage(...) が English から ChineseChina まで26回並んで終わっていた。
MyLanguagesEnum.Japanese = 26 は定義されているのに、登録行だけが無い。
選択肢に出ない理由も同じところにあった。
MyTexts.LoadSupportedLanguages() は
Localization/*.resx のファイル名から ja-JP を拾い、
カルチャ名→言語IDの辞書を引くという実装で、その辞書も同じAddLanguageが作っている。
つまり1行足すだけで「一覧に出る」と「読み込める」が同時に直る。
言語コンボボックスは切り替える前の言語のフォントで描かれる。
英語で遊んでいる人にはFontDataPA(CJKグリフ0個)が使われるので、
日本語と入れると選択肢が豆腐になって選べなくなる。
公式が中国語を"Chinese"と英語表記にしているのと同じ理由なので、
"Japanese"に合わせた。
ILパッチを当てて起動してもまったく同じ場所で同じように落ち続けた。
デプロイ済みDLLのMD5は正しい。PowerShellでリフレクションして
m_languageIdToLanguage を覗くとちゃんと27件あって日本語も入っている。
それでもゲームだけが旧挙動——という状態で1時間溶かした。
詳細と回避策(MVIDを変える/効いていれば壊れる仕掛けで1手で確定させる)は
前半の「.NET / IL解析が必要な時」に一般化して書いた。
| 読み込み順 | ファイル | 役割 |
|---|---|---|
| 1 | MyTexts.resx / MyCommonTexts.resx / MyCoreTexts.resx | 英語(基底)。全キーがここにある |
| 2 | *.<culture>.resx(例 *.ja.resx) | 言語レベル。この作品では存在しない |
| 3 | *.<culture>-<subculture>.resx(*.ja-JP.resx) | 実際の日本語。後勝ちで上書き |
この重ね方のおかげで未訳キーは自動的に英語で出る—— 「未翻訳は必ず原文へフォールバック」を自前で実装する必要がない。 逆に言えばja-JP側にキーが無いこと自体が「意図的に英語のまま」の表明になるので、 訳さないと決めたものは書かないのが正しい(235件をこの扱いにした)。
| 状態 | 件数 | 対応 |
|---|---|---|
| 公式訳あり | 約9,300 | そのまま使用(用語もこちらに合わせた) |
| ja-JP側にキーが無い | 689 | 新規翻訳。サービスターミナル・契約文面・操作名・DLCブロック名など |
| キーはあるが英語のまま | 270 | 上の689件と合わせて精査。記号・制御IDを除いて翻訳 |
| 意図的に英語のまま | 235 | Good.botの入力トリガー語、チャットコマンド名、楽曲名、単位記号、キー刻印、開発用プレースホルダ |
ChatBot_Generic_Q0 = "Hi" のような_Q<n>で終わるキーは、
画面に出る文字列ではなくプレイヤーが打ち込む言葉のマッチング候補だった。
ChatCommand_Smite = "Smite" も同様で、コマンド語そのもの。
公式の日本語版もこれらは英語のまま残していた——
つまり「公式訳が英語のまま置いているキー」は、未訳ではなく判断の結果である可能性を先に疑う。
機械的に「英語のまま=未訳」と判定して全部訳すと、静かに機能を壊す。
FontDataJP(4ページ・1,450グリフ)に対して、
同梱の公式日本語テキストが使っている文字のうち269字にグリフが無い。
つまりこのゲームは、日本語を有効にできたとしても最初から一部が豆腐になる状態だった。
自分の訳文を足すと不足は306字になったので、新しいページ(FontDataJP-4.dds)を
1枚追加して全部そこに置いた。出荷済みの4ページは1バイトも触っていない。
| 項目 | 実測値 |
|---|---|
| 形式 | XNA bitmapfont XML + DDS(BC7_UNORM_SRGB・1024×1024・11ミップ) |
| セル | 全角 47×50px / aw=31 / lsb=-7(=幅 = aw + 16) |
| 描画パラメータ | Microsoft JhengHei Bold 31px を セル内 (+7, +3) に描画(誤差最小化で決定) |
| 影付きバリアント | RGB=カバレッジ、A=max(cov, blur(cov, σ=2.2) × 1.5) |
Content/Data/Fonts.sbc が「フォント種別 × 言語 → アトラス」の割り当て表になっていて、
18種類のフォント定義それぞれに ChineseChina と Japanese の分岐が書かれている。
ただし等幅フォント(LCDパネル・スクリプト表示)だけ Japanese の行が抜けていた——
中国語には割り当てがあるのに日本語には無い、という取りこぼし。
そのままだとLCDの日本語が全部消えるので、1ブロック追記した。
データ駆動の割り当て表を見つけたら、「全部の行に全部の言語があるか」を機械的に数える。
本体を配布した後で「進行表示だけ英語」と指摘されて発覚した。
シナリオのテキストはContent/Data/Localizationではなく
各シナリオのLocalization/フォルダにあり、
<Context>.sbl(マニフェスト)+<Context>.resxの対で、
言語ごとに<Context>.<lang>.sbl/.resxを並べる方式。
9言語ぶんが揃っている中で日本語だけ0枚だった。一般化した話は前半の
「コンテンツ同梱のローカライズを見つける」の節に書いた。
| シナリオ | 件数 | 内容 |
|---|---|---|
| サバイバルについて学ぼう | 173 | チュートリアルの全目標・Good.botの解説・ヒント |
| ファーストジャンプ(5ミッション) | 591 | 物語の会話・目標・GPS名・LCDパネルの記録文・チェックポイント名 |
| ネバー・サレンダー / ウラン強奪戦 / スクラップレース ほか | 73 | ウェーブ表示・レースのHUD・チーム名 |
Frostbite(DLC)だけはこの仕組みを持たず、目標文がビジュアルスクリプトに 直書きされていた。同じノード種別に出現させるプレハブ名も入っていたため、 出力先が表示系の関数かどうかをグラフで辿って25件だけを書き換えた。
プレイ中の人から「左上のクエストログ("Good.bot: Remote Control")が英語」と報告された。 文面は訳文ファイルに入っていたので、翻訳漏れではなかった。
MyLocalizationSessionComponentが開始時の
.sbl一覧をCampaignPathsとしてセーブし、ロード時はそれしか読まない。
MyHudQuestlogは解決済みの文字列を保存・復元する。
対処はVRage.Game.dllのMyLocalization.LoadBundle先頭に
「隣の.ja.sblを足す」メソッド呼び出しを注入(補助メソッドは .NET Framework の
cscでC#からビルドし、dnlibで命令・例外ハンドラごと複製して埋め込んだ)+
インストーラーでの既存セーブのクエストログ置換(846→907件の対応表)。
英語のまま始めたセーブから、次のクエストが日本語で出ることを実機で確認した。vs_build.pyで全シナリオと共通スクリプトを対象に、定数59・複製1・直書き引数77の
計137か所(30ファイル)に広げた。Scrap RaceのHUDボードが
「建造フェーズ開始まで 4 分」と表示されるところまで確認。| もの | 用途 |
|---|---|
| scen_build.py / scen_validate.py | シナリオ用の.ja.sbl+.ja.resx生成と検査 |
| vs_build.py | ビジュアルスクリプトのグラフを辿り、表示にしか届かない文字列だけを置換(定数・未配線の直書き引数。識別子と共用なら複製して付け替え) |
| vs_sweep*.py / vs_trace.py | 直書き文字列の洗い出しと、連結ノードを辿った行き先の確認 |
| JpHelper.cs | VRage.Game.dllに埋め込む「隣の.ja.sblを足す」処理の原本(例外は握りつぶして読み込みを止めない) |
| qlog_map.py | 既存セーブのクエストログを置換するための英文→訳文表(インストーラーが使う) |
| ilspycmd | MyTexts/MyLanguage/MyConfigの逆コンパイル。-tで1クラスだけ取り出す |
| dnlib(自作パッチャ) | ..cctorへの7命令挿入+MVID変更によるNGen無効化 |
| build.py | overlay/*.json を ja-JP resx へ合成。空overlayでバイト一致を往復検査に使う |
| bc7.py | BC7モード6エンコーダ+ミップ生成+DDS書き出し(numpyでブロック一括処理) |
| build_font.py | 不足グリフの算出→レイアウト→2バリアント描画→xml追記 |
| validate.py | 置換トークン照合/想定外スクリプト検出/往復一致/全文字にグリフがあるか/参照ページの実在 |
| shot.ps1 / click2.ps1 / key.ps1 | 実機の起動・スクリーンショット・メニュー操作。マウスはmouse_event、キーはSendInputのスキャンコード |
SendInputでマウスの押下と解放を間を空けずに送ると、
カーソルのホバー(ボタンのハイライト)は効くのにクリックだけが素通りした。
mouse_eventで押下 → 120ms待つ → 解放にしたら一発で通った。
キーボード側はSendInputのスキャンコードで問題なかったので、
「入力が効かない」の原因は経路だけでなく、タイミングのこともある。
以降は3作それぞれの解析結果を、判明した順・確定した仕様のまま詳細に残したもの。 新しいUnity製ゲームに当たった時は、特に「Lucius II」「Lucius III」の節を先に見ると 具体的なコード例(UnityPyの使い方、IL解析の実例)がそのまま使える。
エンジンが変わったので、資産の扱いに関わるコードは全滅した。逆に、 作業の進め方とQAの仕組みはそのまま効いた。前作で一番時間を食ったのは 「フォント形式の解読」だったが、Lucius IIではそのフォント問題自体が存在しなかった (標準ダイナミックフォント)。代わりに時間を食ったのはバイナリテーブル構造の解読だった — 山場は「フォント」から「データ構造」に移った、というのが実際の結果。
その後Homefrontでは「使われている資産の特定」、Deadpoolでは「パッケージの書き戻し方」、 Serious Sam 2では「同梱エディタでのフォント再生成」、Vox Machinaeでは 「訳文は正しいのにキーが違う、の究明」が山場になった。 毎回どこが山場になるかは事前に分からないというのが7作通じての結論。
| 前作の成果物 | 続編での結果 | 理由・注記 |
|---|---|---|
data/tr/*.tsv の翻訳運用 |
そのまま使えた | 英語キー→訳文のTSVをカテゴリ別に分割し、ビルド時にマージ。原文シートは触らない、という形は engine 非依存だった。 |
tools/game.ps1 |
そのまま使えた | ウィンドウのスクショ・クリック・キー送出。プロセス名だけLucius→LuciusIIに変更。 |
tools/gamepath.py |
ほぼそのまま使えた | Steamライブラリ横断の自動検出。実行ファイル名の判定部分だけ差し替えた。 |
tools/package.py / install.bat / uninstall.bat |
そのまま使えた | バックアップ→生成→適用→復元の骨格。パス名だけ差し替えた。Homefront・Deadpoolでも同じ骨格を使った。 |
tools/eefont.py / make_font.py |
丸ごと不要だった | Esenthel専用な上に、Lucius IIはダイナミックフォントでフォント差し替え自体が不要だった。DXT5エンコーダの出番も無かった(HomefrontとDeadpoolで再び必要になった)。 |
tools/xml_tools.py |
作り直し | SpreadsheetML専用。続編はXMLですらなく独自バイナリだったため、localize_core.py を新規開発した。 |
| 言語スロット上書きの方針 | そのまま使えた | 続編も8言語固定で日本語スロットが無く、English を上書きする判断は同じだった。Homefront・Deadpoolでも同じ判断になった。 |
ゲーム内テキストは ほぼ全部が1ファイルに入っていた。台詞だけでなくメニュー・アイテム説明・ チュートリアル・章タイトル・謎かけ・ナレーションまで含めて965行。
| ファイル | 形式 | 内容 |
|---|---|---|
| Data/localization/subtitles_<lang>.xml | Excel 2003 SpreadsheetML | 全965行。列は 話者 / 行番号 / 英語原文 / 訳文 / ID。ゲームが読むのは4列目。 |
| Data/localization/lang.ini | UTF-16LE | [Language] / lang= en の2行だけ。任意のコードを書ける。 |
| Data/localization/credits_<lang>.txt | UTF-8 + BOM | 行番号 TAB 本文。9割が freesound.org のクレジット。 |
| Data/localization/keycodes_<lang>.txt | UTF-8 | キー名の表示テキスト。英語のままが無難。 |
| Data/progress/*.txt | UTF-8 | 英語のみ。実際には上のシート側が使われる。 |
en / es / pl / hn は ANSI(cp1252)、de / fr / it / ru / cn は UTF-16LE + BOM。
パーサはBOMを見て切り替えている。つまり日本語は UTF-16LE で書けば通る。
公式の簡体字中国語版が <br> なしの長文を入れているので、
CJKの自動折り返しは効くことも同時に分かる。
最大の壁だった。Data/fonts/*.font は Esenthel 独自のビットマップアトラス形式。
原版は漢字 20,940 字を持ちながらひらがな・カタカナが1文字も無い(中国語対応時に漢字だけ足したため)。
以下は既存3フォントをバイト単位で誤差ゼロにパースできた確定仕様。
矩形の関係式は rect幅 = width + padd_l + padd_r、
rect高 = height + padd_t + padd_b。ミップは 最小4×4にパディングされる
(ここを外すとサイズ計算が合わない)。グリフは文字集合を渡した順に並ぶ、ソートされていない。
アトラスは白抜き文字で、RGBとアルファの両方に同じカバレッジが入っている。
.txds の参照を辿ると、ゲームが実際に使うのは
Times New Roman64(メニュー・字幕)、TrebuchetMS(チュートリアル・TIPS)、
Tekton(ノート)、daryl_short(進行状況ボード)の4つ。
Arial32/64 と Times New Roman32 は未参照の残骸で、合計190MBを触らずに済む。
誤った仮説を2回引いて、2回とも実機のクラッシュか "MISSING TRANSLATION" で気づいたので、 以下は最終的に正しかった仕様と、その過程で踏んだ地雷を分けて書く。
| 場所 | サイズ | 中身 |
|---|---|---|
| LuciusII_Data/mainData | 5.8 MB | ローカライズテーブルを含む Localizator MonoBehaviour が1個。 |
| LuciusII_Data/level0〜11 | 8.7〜102 MB | 全12ファイルそれぞれに同じテーブルの複製が独立して存在する。1300152バイトの同一サイズオブジェクトが13ファイル全部にある。 |
| LuciusII_Data/resources.assets | 681 MB | ローカライズ本文は入っていない。ムービー字幕のTextAssetはあるが実際には参照されないキー保持用の残骸。 |
| media/*.subs | 11 ファイル | 平文のムービー字幕。ゲームは読んでいない。 |
その仮定でプロローグを直したら "MISSING TRANSLATION" が出た。
原因はプロローグの cutscene が level1 内の
Localizator インスタンスを参照していたこと。13ファイル全部に同じ変更を適用する必要がある。
各ファイルを UnityPy.load() して "ItemDescrpition" を含む
MonoBehaviour を get_raw_data() で総当たりすれば毎回同じオブジェクトが見つかる。
文字列は全て u32 LE 長さ + UTF-8バイト列 + 次の4バイト境界までゼロ埋め。
エントリはカテゴリキー・翻訳数・翻訳配列・検索キーの4部構成で、カテゴリキーは
同じ値("HUD" 等)が何百回も繰り返し出現する。
パース開始位置は data.find(b"ItemDescrpition") - 4。
終了はraw dataの末尾ちょうどで、境界を1バイトも余さず消費できていれば、パーサーが正しい証拠になる。
CutsceneHandler.CheckSub() → Localizator.Get.Translate(text)。
Translate() は渡された文字列を正規化して
Dictionary<string, LocalObject> を引き、
translations[CurrentLanguage] を返す。辞書のキーは原文(英語)そのもので、
字幕データやミッション文字列は常に英語のまま渡される。
正しい対処は常に translations[0] だけを書き換え、original には一切触れないこと。
import UnityPy
env = UnityPy.load(path) # 読み込みと書き込みは別ファイルにすること
target = None
for obj in env.objects:
if obj.type.name == "MonoBehaviour":
try:
raw = obj.get_raw_data() # TypeTree不要、生バイト列がそのまま取れる
except Exception:
continue
if b"ItemDescrpition" in raw:
target = obj
break
raw = target.get_raw_data()
new_raw = rebuild(raw, translate_fn) # 独自パーサーでエントリ単位に再構築
target.set_raw_data(new_raw)
result = env.file.save() # オブジェクトテーブルを自動で再計算
with open(tmp_path, "wb") as f:
f.write(result)
# tmp_path はここで初めて os.replace() で元のファイルへ
UnityPy.load(path) の直後に同じ path へ直接書き込むとファイルが破損する
(681MBのファイルが数十KBに縮んだ実例あり)。必ず path + ".tmp" に書いてから
os.replace(tmp, path)。この置換が PermissionError になる場合は
UnityPyがハンドルを保持しているので、別プロセスでリトライすれば通る。
| カテゴリ | 件数 | 内容 |
|---|---|---|
| HUD | 871 | ナレーション・ミッション目標・UI文言 |
| Subtitle | 551 | NPCとの会話 |
| ItemDescrpition | 446 | アイテム説明(devのtypoで2種類ある) |
| ItemName | 312 | アイテム名 |
| EnemyDescription | 149 | NPC調べテキスト |
| Achievement | 130 | 実績タイトル/説明が交互に並ぶ |
| Menu | 38 | UI文言・オプション項目 |
| Enemy | 15 | 状態表示(短い単語) |
Lucius IIIも同じUnityエンジンでありながらローカライズの実装方式が全くの別物だった。 シリアライズ済みオブジェクトにテーブルが埋め込まれていたLucius IIとは違い、 Lucius IIIは平文のCSVファイルを起動時に動的読み込みする方式に変わっていた。
Lucius3_Data/StreamingAssets/Localization/ に7言語のファイルが
アセットパック外の平文で置かれている。UnityPyも逆コンパイラも不要。
CSVと名乗ってはいるが中身は id|original|translation という単純なパイプ区切り
(引用符エスケープなし)。UTF-8・BOM無し・CRLF。
<br>で改行、*語+で強調
'\n' で分割 → Trim() → '|' で分割、というだけ。
ただし分割は無制限のため、翻訳文に | を含めると3列目以降がまるごと捨てられる。
日本語訳に | を書かないこと、改行は必ず <br> にすることの2点だけ守れば安全。
Menu_LanguageSelector.Start() はドロップダウンの選択肢を、
フォルダをスキャンして集めたファイル名一覧でそのまま埋めている。
ハードコードされた言語リストが存在しないため、日本語.csv を1つ置くだけで
設定メニューに「日本語」が自動的に増える。既存ファイルには一切触れず、
新規追加だけで完結した(バックアップという概念そのものが不要になった唯一のケース)。
ReadFiles() は最初のCSVだけを「マスター」として扱い、そのファイルの
original 列を全エントリの検索キーとして確定させる(2番目以降の
original は完全に無視され、id の一致だけで追記される)。
日本語.csv(U+65E5)も 中文.csv(U+4E2D)も
ラテン文字ファイル名よりコードポイントが大きく、確実に English より後に読まれる。
追加する分には安全だが、既存ファイルの命名を変える改造をするならこの前提を要確認。
言語を切り替えた直後は選択中の項目名だけが更新され、周囲のラベルは英語のままだった。
設定パネルを確定してメニューに戻ると画面全体が正しく再翻訳される。
LocalizeComponent は常時ポーリングせず、パネルの再表示(OnEnable)を
トリガーに翻訳し直す設計と見られる。この遅延を「翻訳が効いていない」と早合点しないこと。