ノベルゲームのテキストをリアルタイムに抽出するツール Textractor がすごい

Textractorというノベルゲームのテキストをリアルタイムに抽出するツールがある。

https://github.com/Artikash/Textractor

このツールは起動中のノベルゲーム内のテキストをリアルタイムに抽出し、翻訳の結果も一緒に出してくれる。 海外のノベルゲームファンが日本語のノベルゲームをプレイするために開発されたものと思われるが、ソースコードがGPLv3ライセンスで公開されており使っている技術が面白かったので紹介したい。

f:id:castaneai:20220128182737p:plain
https://github.com/Artikash/Textractor/blob/master/README.md の画像を引用

Textractorはテキスト抽出をどのように実現しているのか? OCR技術で画面上のテキストを認識していると思ってしまいそうだが、実際は違う。 起動中のノベルゲームのプロセスに潜り込み、テキストが渡される関数の呼び出しを監視しているのである。

別プロセスへの潜り込み

Textractorと対象のノベルゲームはもちろん別々のプロセスだが、ゲーム内の関数を監視するにはゲームのプロセス内部に入り込む必要がある。 そこで、Textractorではこの別プロセスへの潜り込みにDLL Injectionを使っている。Windowsでは昔から他プロセスへの介入する手段として一般的な方法で、具体的にはCreateRemoteThreadで対象のプロセス内にスレッドを作り、そこでLoadLibraryを呼び出す流れになっている。

https://github.com/Artikash/Textractor/blob/f732f488e644f3508e925049318c9c3f261ec177/host/host.cpp#L192

この処理で texthook.dll というDLLファイルがゲームプロセス内に読み込まれ、このDLLファイル内の処理は対象のゲーム内で動く。

テキスト取得関数

対象のプロセスに潜り込んだら、次はテキスト取得関数の設定をする。 Windowsにおいて文字を描画する関数はだいたい決まっているため、その関数を監視すればよい。 とはいえ、そのようなAPIはひとつではないのでTextractorはまず候補になる関数をすべて列挙する。

列挙した関数の情報をTextractorのプロセス側に伝える必要があるため、Memory-mapped fileを使ってプロセス間での情報共有をしている。Textractor側はこの情報を読み取り、利用者はどの関数を監視するか選ぶことができる。

f:id:castaneai:20220203212135p:plain

テキストのリアルタイム抽出

監視する関数が決まれば、あとは関数が呼び出されるたびにTextractorにテキストの内容を送信すればよい。 繰り返すが、監視をするtexthook側(ゲーム側)とTextractorは別プロセスなので、プロセス間でストリーミング的な通信の仕組みが必要になる。そこでTextractorはNamed pipeを使ってリアルタイムにテキストデータを送信している。

Textractorは受信したテキストを表示する。その際にExtensionsという仕組みで受信したテキストそれぞれに翻訳等の処理を挟めるようにもなっている。

f:id:castaneai:20220203212522p:plain

関数のフック

テキスト表示関数を監視するといったが、実行中のプロセス内の関数呼び出しをどうやって監視するのか? これは 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;
}

低レイヤーな処理を書いたことがある人ならなんとなくわかると思うが、吉里吉里特有の関数のパターンをプロセスメモリ内からバイナリ検索して割り出している という変態的な処理だった。

この巨大なソースコードを見ればわかるとおり、 吉里吉里だけでなく数多くのエンジンに対する特化検索を ゴリ押し 筋肉的に書いているのは本当にすごい。 海外にはそこまでしてでもノベルゲームを快適に遊びたい低レイヤーに詳しいハッカーがいるのか、と素直に感心してしまった。


  1. 詳しくはminhookの記事の “How It Works” に書かれている。