Несколько месяцев назад на Reddit был опубликован пост, где описывалась игра, в которой использовался клон Блокнота с открытым исходным кодом для обработки всего ввода и рендеринга. Читая об этом, я подумал, что было бы здорово увидеть что-то похожее, работающее со стандартным Блокнотом Windows. Тогда у меня было слишком много свободного времени.
В итоге я создал игру Snake и небольшой трассировщик лучей, которые используют стандартный Блокнот для всех задач ввода и рендеринга, и попутно узнал о DLL Injection, API Hooking и Memory Scanning. Описание всего, что я узнал в процессе работы, может оказаться интересным чтением для вас.
Сначала я хочу рассказать о том, как работают сканеры памяти, и как я использовал их, чтобы превратить notepad.exe в цель рендеринга со скоростью 30+ кадров в секунду. Я также расскажу о построенном мною трассировщике лучей для визуализации в Блокноте.
Отправка ключевых событий в блокнот
Начну с того, что расскажу об отправке ключевых событий в запущенный экземпляр Блокнота. Это была скучная часть проекта, поэтому я буду краток.
Если вы никогда не создавали приложение из элементов управления Win32 (например, я этого не делал), вы можете быть удивлены, узнав, что каждый элемент пользовательского интерфейса, от строки меню до кнопки, технически является собственным «окном», и отправка ключа ввода в программу включает отправку этого ввода в элемент пользовательского интерфейса, который вы хотите его получить. К счастью, Visual Studio поставляется с инструментом под названием 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, чтобы находить (и менять) отображаемый текст. Я также узнал три важных факта о Блокноте:
В окне редактирования Блокнота экранный текст сохраняется в кодировке UTF-16, даже если в правой нижней части окна указано, что ваш файл имеет формат UTF-8.
Если бы я продолжал удалять и набирать одну и ту же строку, CheatEngine начал бы находить несколько копий этих данных в памяти (возможно, буфер отмены?)
Я не мог заменить отображаемый текст более длинной строкой. Это означает, что Блокнот не выделял текстовый буфер заранее
Создание сканера памяти
Несмотря на невозможность изменить длину текстового буфера, найденный функционал выглядел многообещающе, и я решил написать собственный небольшой сканер памяти для проекта.
Я не смог найти много информации о создании сканеров памяти, но в блоге Криса Веллонса говорится о сканере памяти, который он написал для своего читерского инструмента. Используя эти сведения и немного опыта работы с 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!
Следующей (и последней) частью моих поисков по созданию игры в реальном времени в Блокноте было выяснить, как обрабатывать ввод данных пользователем. Если вы хотите большего, следующий пост можно найти здесь!