ゲームのスクリーンショット-難しい方法

さて、スクリーンショットの作成で何がそんなに難しいのでしょうか? どうやら-オペレーティングシステムから提供された関数を呼び出して、完成した画像を取得します。 確かに多くの人がこれを複数回行っていますが、それでも、フルスクリーンのDirectXまたはOpenGLアプリケーションのスクリーンショットを撮って撮ることはできません。 むしろ、可能ですが、結果として、このアプリケーションのスクリーンショットではなく、黒で塗りつぶされた黒い長方形が得られます。



これは、フルスクリーンゲームの場合、フレームがビデオカードによってレンダリングされ、通常のRAMに入らない場合があるためです。 その結果、OS自体を含む誰もフレームの内容を知りません。



おそらく、フレームを取得する唯一の信頼できる方法は、ゲームプロセスに潜入し、directxまたはopengl apiを使用して、プロセスにビデオメモリからフレームを抽出させ、スクリーンショットを撮るアプリケーションに転送することです。 画面からビデオを記録し、ストリーミングするために、ほとんどのプログラムで使用されるのはこの手法です。 同じアプローチを使用して、必要に応じてゲームの上に何かを描くことができます。



外部プロセスにコードを埋め込むには、dllインジェクションと呼ばれる方法が伝統的に使用されます。 実行可能コードを含むdllを記述する必要があります。 dllは次のようになります。



#include <windows.h> DWORD WINAPI MainLoop(LPVOID) { //    event loop } extern "C" { __declspec (dllexport) BOOL __stdcall DllMain(HMODULE, DWORD ul_reason_for_call, LPVOID) { if (ul_reason_for_call == DLL_PROCESS_ATTACH) { DWORD thrID; CreateThread(0, 0, MainLoop, 0, 0, &thrID); } return TRUE; } }
      
      







dllを実装するには、外部プロセス内にメモリを割り当て、実装されたdllのアドレスをそこに書き込み、このdllをロードするプロセスを開始する必要があります。



 bool InjectDll(int pid, const std::string& dll) { HANDLE hProcess = OpenProcess(PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ, FALSE, pid); HMODULE hKernel32 = ::GetModuleHandle(L"kernel32.dll"); void* remoteMemoryBlock = ::VirtualAllocEx(hProcess, NULL, dll.size() + 1, MEM_COMMIT, PAGE_READWRITE ); if (!remoteMemoryBlock) { return false; } ::WriteProcessMemory(hProcess, remoteMemoryBlock, (void*)dll.c_str(), dll.size() + 1, NULL); HANDLE hThread = ::CreateRemoteThread(hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)::GetProcAddress(hKernel32, "LoadLibraryA"), remoteMemoryBlock, 0, NULL); if (hThread == NULL ) { ::VirtualFreeEx(hProcess, remoteMemoryBlock, dll.size(), MEM_RELEASE); return false; } return true; }
      
      







次に、埋め込みコードとメインアプリケーション間の相互作用スキームを決定する必要があります。 Windowsでは、プロセス間通信にはさまざまな方法があります-ファイル、ソケット、共有メモリ、名前付きパイプなど。 開発には、Qtを使用します。QLocalSocketクラスとQLocalServerクラスがあり、Windowsでは名前付きパイプの上で動作します。これはまさに必要なものです。 まず、dll内でqt-actionイベントループを実行します。



 DWORD WINAPI MainLoop(LPVOID) { if (QCoreApplication::instance()) { //        qt  QEventLoop loop; TInjectedApp myApp; return loop.exec(); } else { int argc = 0; char** argv = nullptr; QCoreApplication loop(argc, argv); TInjectedApp myApp; return loop.exec(); } }
      
      







これで、qtのすべての機能を使用できるTInjectedAppクラスを実装できます。 メインアプリケーションの側でQLocalServerを作成し、接続の待機を開始し、dllの側でQLocalSocketを作成し、それを介してメインアプリケーションに接続します。 QLocalSocketの使用については詳しく説明しません-その使用例は多数あります。また、記事の最後にあるリンクから完全なソースコードを確認することもできます。



そして-プロセスにコードを導入する方法と、それと対話する方法を見つけました。 次に、プロセス内で実際にスクリーンショットを取得する必要があります。 これをdirectx9の例で検討してください。 directx apiを使用して、ビデオカードのバックバッファーを取得できます。 ただし、このためには、IDirect3DDevice9へのポインターを見つける必要があります。 タスクは次の要因によって複雑になります。まず、directxには、既存のIDirect3DDevice9へのポインターを取得するapiメソッドがありません-新しいものを作成するだけです。 第二に、展開しているアプリケーションのソースコードにアクセスできず、このデバイスが作成された場所、保存された変数、保存された場所を正確に知りません。



とにかくこのデバイスを見つける方法は? 最初のオプションは、アプリケーションメモリ全体を調べて、探しているものと内容が似ているオブジェクトを見つけることです。 ほとんどの場合、このクラスのすべてのオブジェクトには、多くの同一のメンバーと、仮想関数の同じまたは類似のテーブルがあります-これで十分です。 しかし、この方法にはいくつかの欠点があります。 第一に、それは信頼できません(突然、一部のアプリケーションでは、探しているクラスの一部のメンバーが異なります)、そして第二に、遅いです(アプリケーションに割り当てられたメモリ全体の完全なパスには多くの時間がかかる可能性があります)。



別の方法があります。 IDirect3DDevice9オブジェクトのアドレスはわかりませんが、このオブジェクトで機能する関数のアドレスを簡単に判断できます。 たとえば、すべてのdirectxアプリケーションはIDirect3DDevice9 :: Present関数を呼び出してフレームをレンダリングする必要があります。 そして、最初の引数(this)にはIDirect3DDevice9へのポインターが渡されます。 この関数のアドレスを知っていれば、この関数の呼び出しをインターセプト(フック)し、代わりに独自の関数を実行できます。この関数はIDirect3DDevice9ポインターを受け取り、スクリーンショットを取得します。



Windowsでは、関数呼び出しのインターセプトは次のように実行できます(32ビットアプリケーションの場合)。



 #include <windows.h> #include <stdint.h> #include <iostream> void Foo() { std::cerr << "Foo()\n"; } void Bar() { std::cerr << "Bar()\n"; } void main() { uint8_t* f = (uint8_t*)Foo; uint8_t* b = (uint8_t*)Bar; DWORD t; VirtualProtect(f, 5, PAGE_EXECUTE_READWRITE, &t); uint32_t distance = b - f - 5; *f = 0xE9; *(uint32_t*)(f + 1) = distance; Foo(); }
      
      







最初に、Foo関数のアドレスに5バイトを書き込むことを許可します。 次に、ジャンプする必要があるバイト数(距離)をカウントします。 次に、関数のアドレスにjmpコマンドのオペコード(1バイト)とジャンプ距離(4バイト)を書き込みます。 これで、このコードが実行されると、Foo関数の代わりにBar関数が実行されます。 実際に使用するには、このメソッドを少し変更する必要があります。まず、メモリの古い内容をどこかに保存し、傍受後に復元します。 次に、64ビットアプリケーションのサポートを追加します。



しかし、どのようにしてPresent関数のアドレスを見つけるのでしょうか? 現在は、dllがエクスポートする関数ではありません。つまり、そのアドレスにもアクセスできません(少なくとも直接アクセスできません)。 しかし、Presentがdll自体に実装されているという事実を活用することができ、dllをロードするときは、dllから常に同じオフセットに配置されます。 したがって、dllのアドレスとPresent関数のオフセットがわかっているので、最初の関数を2番目の関数に追加することでPresent関数のアドレスを取得します。



それでもやはり、すべてが私たちが望むほど単純ではありません。 システム内のdllのバージョンに応じて-オフセットは異なる可能性があるため、プログラムにハードコードすることはできません。プログラムを起動するたびにオフセットを再度決定する必要があります。 C ++では、仮想関数のアドレスを見つける既製の方法はありません。 通常-ください、仮想-いいえ。 したがって、次のことを行う必要があります。アプリケーションでIDirect3DDevice9オブジェクトを作成し、このオブジェクトの仮想関数のテーブルでPresent関数のアドレスを確認してから、dllアドレスとPresent関数のアドレスの間のオフセットを検討します。 このオフセットと、別のアプリケーション内に既にロードされているdllのアドレスを知っていると、Present関数のアドレスを見つけて、フックすることができます。



 uint64_t GetVtableOffset(uint64_t module, void* cls, uint32_t offset) { uintptr_t* virtualTable = *(uintptr_t**)cls; return (uint64_t)(virtualTable[offset] - module); }
      
      







ここで、moduleはロードされたdllのアドレス(LoadLibraryが返すもの)、clsは以前に作成されたIDirect3DDevice9へのポインター、offsetはIDirect3DDevice9クラスの仮想関数のテーブル内の関数の番号(現在-17日)です。 プロセスでオフセットを決定するのが最適であり、挿入されたdllにオフセットを渡します。 埋め込みdll内で、バックバッファの内容を抽出することにより、Present関数をインターセプトし、その中のスクリーンショットを撮ることができます。



 void* PresentFun = nullptr; void GetDX9Screenshot(IDirect3DDevice9* device) { IDirect3DSurface9* backbuffer; device->GetRenderTarget(0, &backbuffer); D3DSURFACE_DESC desc; backbuffer->GetDesc(&desc); IDirect3DSurface9* buffer; device->CreateOffscreenPlainSurface(desc.Width, desc.Height, desc.Format, D3DPOOL_SYSTEMMEM, &buffer, nullptr); device->GetRenderTargetData(backbuffer, buffer); D3DLOCKED_RECT rect; buffer->LockRect(&rect, NULL, D3DLOCK_READONLY); QImage img = ConvertToQImage(desc.Format, (char*)rect.pBits, desc.Height, desc.Width); // ... } static HRESULT STDMETHODCALLTYPE HookPresent(IDirect3DDevice9* device, CONST RECT* srcRect, CONST RECT* dstRect, HWND overrideWindow, CONST RGNDATA* dirtyRegion) { UnHook(PresentFun); GetDX9Screenshot(device); return device->Present(srcRect, dstRect, overrideWindow, dirtyRegion); } void MakeDX9Screen(uint64_t presentOffset) { HMODULE dx9module = GetModuleHandleA("d3d9.dll"); PresentFun = (void*)((uintptr_t)dx9module + (uintptr_t)presentOffset); Hook(PresentFun, HookPresent); }
      
      







抽出されたバックバッファを必要な形式(QImageなど)に変換します。これは、これまでずっと取得しようとしていたスクリーンショットになります。 同様に、このプロセスは、directxおよびopenglの他のバージョン用に構築されています。 openglの場合、仮想関数でオフセットを探す必要がないため、一般的なスキームはさらに簡単です。glBeginはdllによってエクスポートされ、そのアドレスが既知です。



私のプロジェクトの1つであるLibQtScreen用に作成したライブラリに、完全なソースコードがあります。 この記事で説明されているスクリーンショットを撮る方法を実装します。 mingwとmsvc、32ビットと64ビットのアプリケーション、opengl、directxを8日から11日までサポートします。



記事とライブラリを書く際の主な情報源は、ストリーミングプログラムのソースobs-studioです。



All Articles