Скриншотим гри — the hard way

Ну що такого складного може бути у створенні скріншота? Здавалося б — поклич функцію, люб'язно надану операційкою і отримай готову картинку. Напевно багато хто з вас робили це не один раз, і, тим не менше, не можна просто так взяти і заскріншотіть повноекранне directx або opengl додаток. А точніше — можна, але в результаті ви отримаєте не скріншот цього додатка, а залитий чорним прямокутник.

Так відбувається через те, що для повноекранних ігор кадр рендерится відеокартою і в звичайну оперативку може навіть не чинити. В результаті ніхто, в тому числі і сама операційка не знає вмісту кадру.

Мабуть, єдиний надійний спосіб отримати кадр — проникнути всередину ігрового процесу і, використовуючи directx або opengl api, змусити процес отримати кадр з відеопам'яті і передати його додатком яке робить скріншот. Саме ця техніка використовується в більшості програм для запису відео з екрану і стрімінг. Цей же підхід можна використовувати і при необхідності промалювати щось поверх гри.

Для впровадження коду в чужій процес традиційно використовують метод під назвою dll injection. Необхідно написати dll в якій буде міститися виконуваний код. Виглядає dll приблизно так:

#include <windows.h>

DWORD WINAPI MainLoop(LPVOID) {
// Тут запускаємо наш event loop
}

extern "С"
{

__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 <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();
}


Спочатку — дозволяємо запис 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.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.