Textractorというノベルゲームのテキストをリアルタイムに抽出するツールがある。
https://github.com/Artikash/Textractor
このツールは起動中のノベルゲーム内のテキストをリアルタイムに抽出し、翻訳の結果も一緒に出してくれる。 海外のノベルゲームファンが日本語のノベルゲームをプレイするために開発されたものと思われるが、ソースコードがGPLv3ライセンスで公開されており使っている技術が面白かったので紹介したい。
Textractorはテキスト抽出をどのように実現しているのか? OCR技術で画面上のテキストを認識していると思ってしまいそうだが、実際は違う。 起動中のノベルゲームのプロセスに潜り込み、テキストが渡される関数の呼び出しを監視しているのである。
別プロセスへの潜り込み
Textractorと対象のノベルゲームはもちろん別々のプロセスだが、ゲーム内の関数を監視するにはゲームのプロセス内部に入り込む必要がある。 そこで、Textractorではこの別プロセスへの潜り込みにDLL Injectionを使っている。Windowsでは昔から他プロセスへの介入する手段として一般的な方法で、具体的にはCreateRemoteThreadで対象のプロセス内にスレッドを作り、そこでLoadLibraryを呼び出す流れになっている。
この処理で texthook.dll
というDLLファイルがゲームプロセス内に読み込まれ、このDLLファイル内の処理は対象のゲーム内で動く。
テキスト取得関数
対象のプロセスに潜り込んだら、次はテキスト取得関数の設定をする。 Windowsにおいて文字を描画する関数はだいたい決まっているため、その関数を監視すればよい。 とはいえ、そのようなAPIはひとつではないのでTextractorはまず候補になる関数をすべて列挙する。
列挙した関数の情報をTextractorのプロセス側に伝える必要があるため、Memory-mapped fileを使ってプロセス間での情報共有をしている。Textractor側はこの情報を読み取り、利用者はどの関数を監視するか選ぶことができる。
テキストのリアルタイム抽出
監視する関数が決まれば、あとは関数が呼び出されるたびにTextractorにテキストの内容を送信すればよい。 繰り返すが、監視をするtexthook側(ゲーム側)とTextractorは別プロセスなので、プロセス間でストリーミング的な通信の仕組みが必要になる。そこでTextractorはNamed pipeを使ってリアルタイムにテキストデータを送信している。
Textractorは受信したテキストを表示する。その際にExtensionsという仕組みで受信したテキストそれぞれに翻訳等の処理を挟めるようにもなっている。
関数のフック
テキスト表示関数を監視するといったが、実行中のプロセス内の関数呼び出しをどうやって監視するのか? これは Hook と呼ばれる関数に独自の処理を割り込ませる手法を使う。
Textractorはminhookというライブラリを使って関数のフックを実現している。内部的にはx86 (x86_64)のアセンブリを書き換えて関数の先頭に別処理への割り込みを入れるという面白い仕組みになっている1。 これで前述したWindowsにおける文字描画関数をフックしておけば描画される瞬間にテキストを抽出できる。
しかし、これだけで終わりではない。Textractorの最もすごいところは ノベルゲームのエンジンごとに固有のテキスト関数を探してくれる 機能だ。エンジンごとに特化した関数を発見できれば、Windowsの汎用的な文字描画関数よりも完全な状態でのテキストが抽出できるという試みである。
たとえば、吉里吉里というエンジンのテキスト関数を探すコードを抜粋する。
// jichi 1/30/2015: Add KiriKiriZ2 for サノバウィッ� // It inserts to the same location as the old KiriKiriZ, but use a different way to find it. bool InsertKiriKiriZHook2() { const BYTE bytes[] = { 0x38,0x4b, 0x21, // 0122812f 384b 21 cmp byte ptr ds:[ebx+0x21],cl 0x0f,0x95,0xc1, // 01228132 0f95c1 setne cl 0x33,0xc0, // 01228135 33c0 xor eax,eax 0x38,0x43, 0x20, // 01228137 3843 20 cmp byte ptr ds:[ebx+0x20],al 0x0f,0x95,0xc0, // 0122813a 0f95c0 setne al 0x33,0xc8, // 0122813d 33c8 xor ecx,eax 0x33,0x4b, 0x10, // 0122813f 334b 10 xor ecx,dword ptr ds:[ebx+0x10] 0x0f,0xb7,0x43, 0x14 // 01228142 0fb743 14 movzx eax,word ptr ds:[ebx+0x14] }; ULONG range = min(processStopAddress - processStartAddress, MAX_REL_ADDR); ULONG addr = MemDbg::findBytes(bytes, sizeof(bytes), processStartAddress, processStartAddress + range); //GROWL_DWORD(addr); if (!addr) { ConsoleOutput("vnreng:KiriKiriZ2: pattern not found"); return false; } // 012280e0 55 push ebp // 012280e1 8bec mov ebp,esp addr = MemDbg::findEnclosingAlignedFunction(addr, 0x100); // 0x0122812f-0x012280e0 = 0x4F enum : BYTE { push_ebp = 0x55 }; // 011d4c80 /$ 55 push ebp if (!addr || *(BYTE *)addr != push_ebp) { ConsoleOutput("vnreng:KiriKiriZ2: pattern found but the function offset is invalid"); return false; } NewKiriKiriZHook(addr); ConsoleOutput("vnreng: KiriKiriZ2 inserted"); return true; }
低レイヤーな処理を書いたことがある人ならなんとなくわかると思うが、吉里吉里特有の関数のパターンをプロセスメモリ内からバイナリ検索して割り出している という変態的な処理だった。
この巨大なソースコードを見ればわかるとおり、
吉里吉里だけでなく数多くのエンジンに対する特化検索を ゴリ押し 筋肉的に書いているのは本当にすごい。
海外にはそこまでしてでもノベルゲームを快適に遊びたい低レイヤーに詳しいハッカーがいるのか、と素直に感心してしまった。
-
詳しくはminhookの記事の “How It Works” に書かれている。↩