Несколько месяцев назад на Reddit был опубликован пост, где описывалась игра, в которой использовался клон Блокнота с открытым исходным кодом для обработки всего ввода и рендеринга. Читая об этом, я подумал, что было бы здорово увидеть что-то похожее, работающее со стандартным Блокнотом Windows. Тогда у меня было слишком много свободного времени.

В итоге я создал игру Snake и небольшой трассировщик лучей, которые используют стандартный Блокнот для всех задач ввода и рендеринга, и попутно узнал о DLL Injection, API Hooking и Memory Scanning. Описание всего, что я узнал в процессе работы, может оказаться интересным чтением для вас.

Сначала я хочу рассказать о том, как работают сканеры памяти, и как я использовал их, чтобы превратить notepad.exe в цель рендеринга со скоростью 30+ кадров в секунду. Я также расскажу о построенном мною трассировщике лучей для визуализации в Блокноте.

Отправка ключевых событий в блокнот

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

Если вы никогда не создавали приложение из элементов управления Win32 (например, я этого не делал), вы можете быть удивлены, узнав, что каждый элемент пользовательского интерфейса, от строки меню до кнопки, технически является собственным «окном», и отправка ключа ввода в программу включает отправку этого ввода в элемент пользовательского интерфейса, который вы хотите его получить. К счастью, Visual Studio поставляется с инструментом под названием Spy++, который может перечислить все окна, составляющие данное приложение.

Блокнот в Spy++
Блокнот в Spy++

Spy++ обнаружил, что дочернее окно Блокнота, которое я искал, было окном «Редактировать». Как только я это узнал, мне оставалось просто выяснить, как правильно сочетать вызовы функций Win32, чтобы получить HWND для этого элемента пользовательского интерфейса, а затем отправить туда входные данные. Получение HWND выглядело примерно так:

HWND GetWindowForProcessAndClassName(DWORD pid, const char* className)
{
  HWND curWnd = GetTopWindow(0); //0 arg means to get the window at the top of the Z order
  char classNameBuf[256];

  while (curWnd != NULL){
    DWORD curPid;
    DWORD dwThreadId = GetWindowThreadProcessId(curWnd, &curPid);

    if (curPid == pid){
      GetClassName(curWnd, classNameBuf, 256);
      if (strcmp(className, classNameBuf) == 0) return curWnd;

      HWND childWindow = FindWindowEx(curWnd, NULL, className, NULL);
      if (childWindow != NULL) return childWindow;
    }
    curWnd = GetNextWindow(curWnd, GW_HWNDNEXT);
  }
  return NULL;
}

Как только у меня появился HWND для правого элемента управления, рисование символа в элементе управления редактированием Блокнота было просто вопросом использования PostMessage для отправки ему события WM_CHAR.

Обратите внимание, если вы захотите использовать Spy++, то наверняка выберете его 64-разрядную версию. Однако она по необъяснимым причинам не является той версией, которую Visual Studio 2019 запускает по умолчанию. Вместо этого вам нужно будет искать в файлах программы Visual Studio «spyxx_amd64.exe».

Когда всё заработало, мне потребовалось 10 секунд, чтобы понять, что даже если бы я смог найти способ использовать оконные сообщения для рисования полных игровых экранов в Блокноте, это получилось бы слишком медленно, и даже близко не будет похоже на цикл обновления 30 Гц. К тому же это выглядело очень скучно, поэтому я не стал тратить время на поиски способов ускорить процесс.

CheatEngine для хороших парней

При настройке поддельного ввода с клавиатуры мне вспомнилась CheatEngine. Эта программа позволяет пользователям находить и изменять память в процессах, запущенных на их машинах. Чаще всего её используют люди, чтобы получить больше ресурсов/жизней/времени в играх или делать другие вещи, которые огорчают разработчиков игр. Однако программа также может послужить и силам добра.

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

  • С помощью сканера памяти найдите в памяти игры все адреса, по которым хранится значение вашего здоровья (скажем, 100)

  • Сделайте что-нибудь в игре, чтобы изменить свое здоровье до нового значения (например, 92)

  • Переберите все адреса, которые вы нашли ранее (которые хранят 100), чтобы найти те, которые теперь хранят 92

  • Повторяйте этот процесс, пока у вас не будет одного адреса памяти (который, скорее всего, является местом, где хранится ваше здоровье)

  • Измените значения адреса

CheatEngine и Блокнот "подружились"
CheatEngine и Блокнот "подружились"

В принципе, я так и сделал, но вместо значения здоровья  искал память, в которой хранилась строка текста, отображаемая в настоящее время в Блокноте. После любимого мной метода проб и ошибок я научился использовать CheatEngine, чтобы находить (и менять) отображаемый текст. Я также узнал три важных факта о Блокноте:

  1. В окне редактирования Блокнота экранный текст сохраняется в кодировке UTF-16, даже если в правой нижней части окна указано, что ваш файл имеет формат UTF-8.

  2. Если бы я продолжал удалять и набирать одну и ту же строку, CheatEngine начал бы находить несколько копий этих данных в памяти (возможно, буфер отмены?)

  3. Я не мог заменить отображаемый текст более длинной строкой. Это означает, что Блокнот не выделял текстовый буфер заранее

Создание сканера памяти

Несмотря на невозможность изменить длину текстового буфера, найденный функционал выглядел многообещающе, и я решил написать собственный небольшой сканер памяти для проекта.

Я не смог найти много информации о создании сканеров памяти, но в блоге Криса Веллонса говорится о сканере памяти, который он написал для своего читерского инструмента. Используя эти сведения и немного опыта работы с CheatEngine, я смог кое-что сваять, и в результате основной алгоритм для сканера памяти выглядит примерно так:

FOR EACH block of memory allocated by our target process
    IF that block is committed and read/write enabled
        Scan the contents of that block for our byte pattern
        IF WE FIND IT
            return that address

Моя версия сканера памяти составила всего ~ 40 строк кода.

Итерация по памяти процесса

Первое, что нужно сделать сканеру памяти, — это перебрать выделенную для процесса память.

Поскольку диапазон виртуальной памяти для каждого 64-битного процесса в Windows одинаков (от 0x00000000000 до 0x7FFFFFFFFFFF), я начал с создания указателя на адрес 0 и использовал VirtualQueryEx для получения информации об этом виртуальном адресе для моей программы.

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

Как только у меня появилась первая структура MEMORY_BASIC_INFORMATION, итерация по памяти сводилась только к добавлению элементов BaseAddress и RegionSize текущей структуры вместе и передаче нового адреса в VirtualQueryEx для получения следующего набора страниц

char* FindBytePatternInProcessMemory(HANDLE process, const char* pattern, size_t patternLen)
{
  char* basePtr = (char*)0x0;

  MEMORY_BASIC_INFORMATION memInfo;

  while (VirtualQueryEx(process, (void*)basePtr, &memInfo, sizeof(MEMORY_BASIC_INFORMATION)))
  {
    const DWORD mem_commit = 0x1000;
    const DWORD page_readwrite = 0x04;
    if (memInfo.State == mem_commit && memInfo.Protect == page_readwrite)
    {
      // search this memory for our pattern
    }

    basePtr = (char*)memInfo.BaseAddress + memInfo.RegionSize;
  }
}

Приведённый выше код также определяет, зафиксирован ли набор страниц и разрешено ли чтение/запись путём проверки элементов структуры .State и .Protect. Вы можете найти все возможные значения для этих переменных в документации для MEMORY_BASIC_INFORMATION, но значения, которые требовались моему сканеру, имели состояние 0x1000 (MEM_COMMIT) и уровень защиты 0x04 (PAGE_READWRITE).

Поиск байтового шаблона в памяти процесса

Невозможно напрямую прочитать данные в адресном пространстве другого процесса (по крайней мере, я не догадался, как это сделать). Вместо этого мне сначала нужно было скопировать содержимое диапазона страниц в адресное пространство сканера памяти. Я сделал это с помощью ReadProcessMemory.

После того, как память была скопирована в локально видимый буфер, поиск в ней байтового шаблона стал достаточно простым. Чтобы упростить задачу, я проигнорировал возможность того, что в моей первой реализации сканера могло быть несколько копий целевого байтового шаблона в памяти. Позже я придумал метод решения этой проблемы, который избавил меня от необходимости решать её в логике моего сканера.

char* FindPattern(char* src, size_t srcLen, const char* pattern, size_t patternLen)
{
  char* cur = src;
  size_t curPos = 0;

  while (curPos < srcLen){
    if (memcmp(cur, pattern, patternLen) == 0){
      return cur;
    }

    curPos++;
    cur = &src[curPos];
  }
  return nullptr;
}

Если FindPattern() вернул указатель совпадения, его адрес нужно было преобразовать в адрес того же бита памяти в адресном пространстве целевого процесса. Для этого я вычел начальный адрес локального буфера из адреса, который был возвращен FindPattern, чтобы получить смещение, а затем добавил его к базовому адресу блока памяти в целевом процессе. Вы можете увидеть это ниже.

char* FindBytePatternInProcessMemory(HANDLE process, const char* pattern, size_t patternLen)
{
  MEMORY_BASIC_INFORMATION memInfo;
  char* basePtr = (char*)0x0;

  while (VirtualQueryEx(process, (void*)basePtr, &memInfo, sizeof(MEMORY_BASIC_INFORMATION))){
    const DWORD mem_commit = 0x1000;
    const DWORD page_readwrite = 0x04;
    if (memInfo.State == mem_commit && memInfo.Protect == page_readwrite){
      char* remoteMemRegionPtr = (char*)memInfo.BaseAddress;
      char* localCopyContents = (char*)malloc(memInfo.RegionSize);

      SIZE_T bytesRead = 0;
      if (ReadProcessMemory(process, memInfo.BaseAddress, localCopyContents, memInfo.RegionSize, &bytesRead)){
        char* match = FindPattern(localCopyContents, memInfo.RegionSize, pattern, patternLen);

        if (match){
          uint64_t diff = (uint64_t)match - (uint64_t)(localCopyContents);
          char* processPtr = remoteMemRegionPtr + diff;
          return processPtr;
        }
      }
      free(localCopyContents);
    }
    basePtr = (char*)memInfo.BaseAddress + memInfo.RegionSize;
  }
}

Если вы хотите увидеть пример того, как это работает, посмотрите проект «MemoryScanner» в репозитории на github. Попробуйте в Блокноте! (ни на чём другом не пробовал, так что ymmv, ваши результаты могут быть другими).

Использование байтовых шаблонов UTF-16

Как вы помните, Блокнот хранит свой экранный текстовый буфер как данные UTF-16, поэтому байтовый шаблон, который передается в FindBytePatternInMemory (), также должен быть UTF-16. Для простых строк это просто включает добавление нулевого байта после каждого символа. Проект MemoryScanner в github делает это за вас:

//convert input string to UTF16 (hackily)
const size_t patternLen = strlen(argv[2]);
char* pattern = new char[patternLen*2];
for (int i = 0; i < patternLen; ++i){
  pattern[i*2] = argv[2][i];
  pattern[i*2 + 1] = 0x0;
}

Обновление и перерисовка элемента управления редактированием Блокнота

Следующим шагом после того, как я получил адрес отображаемого текстового буфера в Блокноте, было использование WriteProcessMemory для его изменения. Написать код для этого было просто, но я быстро понял, что просто записи в текстовый буфер было недостаточно, чтобы Блокнот перерисовал элемент управления Edit.

К счастью, Win32 api предоставляет функцию InvalidateRect, с помощью которой можно заставить элемент управления перерисовываться.

В целом, изменение отображаемого текста в Блокноте выглядело примерно так:

void UpdateText(HINSTANCE process, HWND editWindow, char* notepadTextBuffer, char* replacementTextBuffer, int len)
{
  size_t written = 0;
  WriteProcessMemory(process, notepadTextBuffer, replacementTextBuffer, len, &written);

  RECT r;
  GetClientRect(editWindow, &r);
  InvalidateRect(editWindow, &r, false);
}

От сканера памяти к рендереру

Разрыв между сканером рабочей памяти и полноценным рендерером блокнота на удивление невелик. Было только три проблемы, которые нужно было решить, чтобы перейти от того, что я успел добиться, к трассировщику лучей,  который и был мне нужен.

Вот эти проблемы:

  • Мне нужно было контролировать размер окна Блокнота

  • Мне всё ещё не удалось увеличить размер текстового буфера на экране

  • Мой сканер памяти не обрабатывал повторяющиеся последовательности байтов

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

В итоге я жестко запрограммировал размер окна Блокнота, а затем подсчитал, сколько символов (моноширинного шрифта) потребуется, чтобы точно заполнить окно такого размера. Затем после вызова MoveWindow я предварительно выделил экранный текстовый буфер, отправив такое количество сообщений WM_CHAR в Блокнот. Это было похоже на читерство, но это хороший вид читерства.

Чтобы убедиться, что у меня всегда был уникальный шаблон байтов для поиска, я просто рандомизировал, какие символы я отправляю в сообщениях WM_CHAR.

Вот пример того, как может выглядеть подобный код. Фактический код в репозитории github отформатирован немного иначе, но работает точно так же.

void PreallocateTextBuffer(DWORD processId)
{
  HWND editWindow = GetWindowForProcessAndClassName(processId, "Edit");

  // it takes 131 * 30 chars to fill a 1365x768 window with Consolas (size 11) chars
  MoveWindow(instance.topWindow, 100, 100, 1365, 768, true); 

  size_t charCount = 131 * 30;
  size_t utf16BufferSize = charCount * 2;

  char* frameBuffer = (char*)malloc(utf16BufferSize);
  for (int i = 0; i < charCount; i++){
    char v = 0x41 + (rand() % 26);
    PostMessage(editWindow, WM_CHAR, v, 0);
    frameBuffer[i * 2] = v;
    frameBuffer[i * 2 + 1] = 0x00;
  }

  Sleep(5000); //wait for input messages to finish processing...it's slow. 
  //Now use the frameBuffer as the unique byte pattern to search for
}

По факту это означало, что сразу после запуска я должен был увидеть, как окно моего Блокнота медленно заполняется случайными символами, прежде чем я смогу получить указатель текстового буфера и очистить экран.

Всё вышеперечисленное зависит от использования известного начертания и размера шрифта для правильной работы. Я собирался добавить код, чтобы заставить блокнот использовать нужные мне шрифты (Consolas, 11pt), но по какой-то причине отправка сообщений WM_SETFONT продолжала портить отображение шрифтов, и мне не хотелось выяснять, что пошло не так там. Consolas 11pt был шрифтом Блокнота по умолчанию в моей системе, и этого мне было достаточно.

Трассировка лучей в блокноте

Объяснение того, как создать трассировщик лучей, выходит далеко за рамки того, о чем я хочу рассказать сейчас. Если вы в целом не знакомы с трассировкой лучей, перейдите на ScratchAPixel и навсегда научитесь этому. Я хочу закончить эту историю быстрым обсуждением тонкостей подключения трассировщика лучей ко всему тому, о чём я только что говорил.

Вероятно, имеет смысл начать с буферов кадров. Чтобы свести к минимуму количество вызовов WriteProcessMemory (как для разумности, так и для производительности), я выделил локальный буфер трассировщика лучей того же размера, что и текстовый буфер Блокнота (количество символов * 2 (из-за UTF16)). Все вычисления рендеринга будут записываться в этот локальный буфер до конца фрейма, когда я использую один вызов WriteProcessMemory для одновременной замены всего содержимого буфера Блокнота. Это привело к действительно простому набору функций для рисования:

void drawChar(int x, int y, char c); //local buffer
void clearScreen(); // local buffer
void swapBuffersAndRedraw(); // pushes changes and refreshes screen. 

Что касается трассировки лучей, то, учитывая низкое разрешение моей цели рендеринга (131 x 30), мне пришлось всё упростить, поскольку «пикселей» просто не хватало для качественного отображения мелких деталей. Я закончил трассировку только одного первичного луча и теневого луча для каждого пикселя, в котором выполняется рендеринг, и я даже думал о том, чтобы отбросить тени, пока не нашел на сайте Пола Бурка красивую плавающую шкалу оттенков серого в цветовую шкалу ascii. Наличие такой низкой сложности сцены и небольшой поверхности рендеринга означало, что мне вообще не придётся распараллеливать рендеринг.

Я также столкнулся с проблемой отображения. Нужно было добиться, чтобы всё выглядело правильно, даже когда персонажи были выше их ширины. В конце концов, я «исправил» это, уменьшив вдвое значение ширины, которое я использовал при расчётах соотношения сторон.

float aspect = (0.5f * SCREEN_CHARS_WIDE) / float(SCREEN_CHARS_TALL);

Единственная проблема, для которой я не нашел рабочего решения, заключается в том, что обновление содержимого элемента управления редактированием Блокнота вызывает очень заметное мерцание. Я пробовал кучу разных вещей, чтобы избавиться от этого, включая попытку удвоить буфер элемента управления редактирования, выделив вдвое большее количество символов и используя сообщения WM_VSCROLL, чтобы «поменять местами» буфер, регулируя положение полосы прокрутки. К сожалению, ничего из того, что я пробовал, не сработало, и мерцание осталось.

Часть 2: Доступен ввод Boogaloo!

Следующей (и последней) частью моих поисков по созданию игры в реальном времени в Блокноте было выяснить, как обрабатывать ввод данных пользователем. Если вы хотите большего, следующий пост можно найти здесь!