Пользователь
0,1
рейтинг
1 января в 21:38

Разработка → Скриншотим игры — the hard way

Ну что такого сложного может быть в создании скриншота? Казалось бы — позови функцию, любезно предоставленную операционкой и получи готовую картинку. Наверняка многие из вас делали это не один раз, и, тем не менее, нельзя просто так взять и заскриншотить полноэкранное directx или opengl приложение. А точнее — можно, но в результате вы получите не скриншот этого приложения, а залитый черным прямоугольник.

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

Пожалуй, единственный надежный способ получить кадр — внедриться внутрь игрового процесса и, используя directx или opengl api, заставить процесс извлечь кадр из видеопамяти и передать его приложению которое делает скриншот. Именно эта техника используется в большинстве программ для записи видео с экрана и стриминга. Этот же подход можно использовать и при необходимости отрисовать что-то поверх игры.

Для внедрения кода в чужой процесс традиционно используют метод под названием dll injection. Необходимо написать 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 есть много различных способов межпроцессного взаимодействия — файлы, 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.
Филипп @bak
карма
60,5
рейтинг 0,1
Пользователь
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (22)

  • +6
    ms-rem жив!
  • 0
    Так происходит из за того, что для полноэкранных игр кадр рендерится видеокартой и в обычную оперативку может даже не поступать. В итоге никто, в том числе и сама операционка не знает содержимого кадра.

    Интересный факт. Это всегда так было?
    • +14
      Ну да, со времён появления видеокарт.
    • +3
      Помню ещё как скриншоты с фильмами через paint смотрели на winxp, повеселее чёрных прямоугольников
  • +1
    В iOS такая же петрушка с OpenGL. Причем, в каждой новой версии рекомендуют свой трюк для получения не черного экрана.
  • +3
    VAC-бан за такие вещи не влепят?
    • 0
      А смысл в стиме это юзать? Там все и так уже есть.
      • 0
        Аргументация? Не хотите вылавливать стимапи и поток видео наружу, ваше право. Но, в общем, если идти по пути ленивого, проще отлавливать трафик стимклиента при трансляциях.
        Хотя вопрос был про скриншоты… F12(default), Screenshot, SteamOverlay, в документации почитайте, граждане минусаторы.
    • +1
      Не влепят. Так работает всё ПО для стримов. VAC отправляет неизвестные dll на анализ, только в случае подтверждения что это чит происходит бан.
      • –2
        Интересно. Можно DLL специально написать с ошибками, чтобы она казалась безобидной, но при получении некоторых кривых данных происходило какое-нибудь переполнение и затирание памяти, адрес которой косвенно определялся пакетом данных. На этом и строить чит.
        • 0
          Можно. Но зачем?
          • +1
            Тот комментарий был приглашением к технической дискуссии на тему «как защищать программы, если необходимо обеспечить совместную работу со сторонними компонентами». Это общая тема, не только в рамках системы Valve.

            Часто в комментариях встречаются неожиданные решения, поэтому здесь и интересно.
        • 0
          Проще внедриться в одну из системных библиотек, но активироваться только при выполнении в контексте конкретной игрушки. Маловероятно, что Valve анализируют массу постоянно обновляющихся системных библиотек, которые могут отличаться в зависимости от редакции ОС, локализации или желания левой пятки MS. Или ещё можно использовать виртуализацию и модифицировать непосредственно регистры во время выполнения. В таком случае никаких модификаций кода вообще происходить не будет.
          • +1
            Если это совсем системная библиотека, находящаяся в System32, то Windows File Protection не даст её запатчить. Если библиотеку модифицировать и положить в директорию с игрой, будет заметно, что системная DLL загрузилась не из System32.

            Если речь о всяких MSVC Runtime, которые распространяют с приложением, то защита знает точные хеши этих DLL.

            Виртуализация не работает без доступа к ядру, а это проблема с приходом обязательной подписи драйверов.
            • 0
              Windows File Protection не даст её запатчить
              SfcDisable.
              Виртуализация не работает без доступа к ядру
              Бред собачий. Виртуализация происходит уровнем ниже, ей по барабану, какую ОС будут запускать в виртуальной машине.
              • +1
                Я подумал, виртуализация такая, как была виртуализация ресурсов для DOS-программ в Windows. Т.е. если программа делает обращение к ресурсу или вызывает какую-то ф-цию, невидимый для неё монитор перехватывает управление, пользуясь средствами отладки CPU.

                В любом случае, на этих двух принципах нельзя построить продукт, который было бы не стыдно отдавать другим людям. «Вот мой скринграббер, но работает он только для игр в вирт. машине, или требует разрушить механизмы защиты OS».
                • 0
                  Да, это скорее для тех самых скрытых от VAC читов и других весёлых модификаций, которые не должны быть обнаружены пользователем и/или специальным софтом.
                  • +1
                    Виртуалка для читов плохо подходит из-за снижения производительности, разве что к малодинамичным играм вроде HearthStone.

                    А SfcDisable требует перезагрузки и подавления системных предупреждений при каждом старте (если речь идёт об особом софте, который юзер не должен заметить).

                    То есть, варианты рабочие, но не могу согласиться с изначальным посылом «Проще внедриться в одну из системных библиотек».
          • 0
            Если игрушка сетевая, проще научиться снифать трафик конкретного процесса.
  • 0
    А можете подробнее описать, почему используется именно такой метод внедрения в процесс, а не SetWindowsHookEx с хуком WH_CBT?
    • 0
      Если честно — особо не раздумывал над методом, а в чем преимущество SetWindowsHookEx?
      • +1
        На вскидку, преимущество только в использовании стандартного документированного API для инжекта вместо закладки на то, что адрес kernel32.dll будет тот же самый.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.