Скриншотим игры — the hard way
Ну что такого сложного может быть в создании скриншота? Казалось бы — позови функцию, любезно предоставленную операционкой и получи готовую картинку. Наверняка многие из вас делали это не один раз, и, тем не менее, нельзя просто так взять и заскриншотить полноэкранное directx или opengl приложение. А точнее — можно, но в результате вы получите не скриншот этого приложения, а залитый черным прямоугольник.
Так происходит из за того, что для полноэкранных игр кадр рендерится видеокартой и в обычную оперативку может даже не поступать. В итоге никто, в том числе и сама операционка не знает содержимого кадра.
Пожалуй, единственный надежный способ получить кадр — внедриться внутрь игрового процесса и, используя directx или opengl api, заставить процесс извлечь кадр из видеопамяти и передать его приложению которое делает скриншот. Именно эта техника используется в большинстве программ для записи видео с экрана и стриминга. Этот же подход можно использовать и при необходимости отрисовать что-то поверх игры.
Для внедрения кода в чужой процесс традиционно используют метод под названием dll injection. Необходимо написать dll в которой будет содержаться исполняемый код. Выглядит dll примерно так:
#include
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 есть много различных способов межпроцессного взаимодействия — файлы, sockets, shared memory, named pipes и прочие. Для разработки я использую Qt — в нём есть класс QLocalSocket и QLocalServer, которые в windows работают поверх named pipes — это как раз то что нужно. Для начала — запустим внутри dll-ки qt-шный event loop:
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();
}
}
Теперь мы можем реализовать класс TInjectedApp в котором можно пользоваться всеми возможностями qt. На стороне нашего основного приложения создадим QLocalServer и начнем ждать подключений, а на стороне dll — создадим QLocalSocket и подключимся через него к основному приложению. Подробно останавливаться на использовании QLocalSocket не буду — существует большое количество примеров его использования, так же вы можете посмотреть полный исходный код по ссылке в конце статьи.
И так — мы разобрались с внедрением нашего кода в процесс и взаимодействием с ним. Теперь необходимо собственно получить скриншот, находясь внутри процесса. Рассмотрим это на примере directx9. Используя directx api мы можем получить backbuffer видеокарты. Но для этого нам необходимо найти указатель на IDirect3DDevice9. Задача осложняется следующими факторами — во первых, у directx нету api методов, позволяющих получить указатель на существующий IDirect3DDevice9 — только на создание нового. Во вторых — у нас нет доступа к исходникам тех приложений в которые мы внедряемся, и мы не знаем где именно создаётся этот девайс, в какую переменную он сохраняется и где вообще его искать.
Как же всё таки найти этот девайс? Первый вариант — это пройтись по всей памяти приложения и найти там объект, похожий по содержимому на то что мы ищем. Скорее всего все объекты этого класса будут иметь много одинаковых членов, а так же одинаковую или похожую таблицу виртуальных функций — этого достаточно для поиска. Но у этого метода есть ряд недостатков. Во первых — он не надежный (вдруг в каком-то приложении какие-то члены класса, по которым мы ищем будут отличаться), и во вторых — он медленный (полный проход по всей выделенный приложению памяти может занимать много времени).
Существует другой способ. Мы не знаем адрес объекта IDirect3DDevice9, но мы легко можем определить адреса функций, которые работают с этим объектом. Например, все directx приложения должны звать функцию IDirect3DDevice9:: Present для рендеринга кадра. И первым аргументом (this) в неё передаётся указатель на IDirect3DDevice9. Зная адрес этой функции, мы можем осуществить перехват (hook) вызова этой функции, и выполнить вместо неё свою функцию, которая получит первым аргументом указатель IDirect3DDevice9 и сделает через него скриншот.
В windows перехват вызова функции можно сделать примерно так (для 32-х битных приложений):
#include
#include
#include
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();
}
Вначале — разрешаем запись 5 байт по адресу функции Foo. Затем считаем количество байт, на которые необходимо осуществить прыжок (distance). Затем — пишем по адресу функции оп-код команды jmp (1 байт) и расстояние прыжка (4 байта). Теперь при запуске этого кода вместо функции Foo выполнится функция Bar. Для практического применения этот метод нужно будет слегка доработать — во первых — сохранять куда-то старое содержимое памяти и восстанавливать его после перехвата. Во вторых — добавить поддержку 64-х битных приложений.
Но как нам узнать адрес функции Present? Present не является функцией, которую экспортирует dll, а значит и её адрес нам тоже не доступен (по крайней мере на прямую). Но мы можем воспользоваться тем фактом, что Present реализован в самой dll, и при загрузке dll она будет всегда располагаться на одинаковом смещении от самой dll. Поэтому, зная адрес dll и смещение функции Present мы получим адрес функции 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 (Present — 17-я). Определять смещение лучше всего в своём процессе, а затем передовать его во внедряемую dll. Внутри внедренной dll теперь можно перехватывать функцию Present и делать внутри неё скриншот путём извлечения содержимого backbuffer-а.
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);
}
Извлеченный backbuffer конвертируем в нужный нам формат (например, QImage) — это и будет скриншот, который мы так долго пытались получить. Аналогичным образом процесс строится и для других версий directx и opengl. Для opengl общая схема даже проще, так как там не нужно искать смещения у виртуальных функций — glBegin экспортируется dll-кой и её адрес известен.
Полный исходный код вы можете посмотреть в библиотеке, которую я сделал для одного из своих проектов, LibQtScreen. В ней реализован описанный в статье метод получения скриншотов. Она поддерживает mingw и msvc, 32 и 64 битные приложения, opengl и directx с 8-го по 11-й.
Основной источник информации при написании статьи и библиотеки — исходники программы для стриминга — obs-studio.