Спидран – это искусство прохождения игры на максимально быстром времени, где каждая секунда может иметь решающее значение. Центральным элементом для игроков, стремящихся к совершенству, является таймер, способный автоматически распознавать ключевые моменты игры, такие как переходы между уровнями и загрузки, чтобы точно фиксировать время прохождения. Для популярных игр на Windows существует множество инструментов, в том числе знаменитый LiveSplit, который поддерживает плагины с авторазделением и исключением времени загрузки. Однако для пользователей Linux подобные решения зачастую недоступны или ограничены по функциональности. Именно поэтому я решил создать собственный таймер для спидрана игры Deus Ex в среде Linux, используя системный и высокоуровневый язык программирования D.
Выбор языка D оказался более чем оправданным. Комбинация низкоуровневого доступа к памяти и системным вызовам с удобствами высокоуровневого синтаксиса и встроенного сборщика мусора позволила легко работать с процессами и при этом создавать удобный интерфейс пользователя. Главное препятствие заключалось в необходимости «внедрить» собственный код внутрь процесса игры – то, что невозможно выполнить простым внешним мониторингом событий. Для начала я исследовал, как игра управляет переходами между уровнями и отображением экранов загрузки. Благодаря особенностям движка Unreal и сохранившемуся исходному коду игровых скриптов можно получить много полезной информации.
Например, в игре существует перечисление, которое называет различные стадии действия уровня, такие как загрузка (LEVACT_Loading), сохранение и прочее. Моя первичная идея заключалась в том, чтобы найти отдельный флаг в оперативной памяти, который меняет значение при входе и выходе из экрана загрузки. Используя утилиту scanmem, я пытался отследить изменение значения флага, но из-за сложной структуры виртуальной машины Unreal память была нестабильной, и адреса быстро изменялись. Поняв, что «быстрый взлом» невозможен, я перешёл к более основательному методу – внедрению кода внутри процесса игры. Это позволило бы мне точно фиксировать состояние загрузки и контролировать таймер напрямую.
Для Linux систем ключевым инструментом стали системные вызовы ptrace и process_vm_readv. Первый даёт возможность отладчику управлять процессом, останавливать и изменять его память, а второй – читать данные напрямую из пространства памяти целевого процесса. Правда запись по process_vm_writev может быть ограничена защитой страниц, поэтому для вставки модификаций использовался ptrace с таргетом по 8 байтов. Определив, что Windows бинарные файлы в формате PE содержат таблицу экспорта с функциями, я нашёл адрес функции LoadMap, отвечающей за загрузку нового уровня. Её внедрение крайне выгодно, поскольку все вызовы LoadMap косвенно переходят к одному адресу через инструкцию JMP, позволяя изменить поведение централизованно.
Здесь же я обнаружил большое количество свободного пространства между инструкциями, заполненного NOP и INT3, что означало возможность хранения дополнительного кода без смещения основной логики. Следующим ключевым шагом было нахождение безопасного участка памяти для хранения собственного флага, отвечающего за состояние загрузки. Анализ карт памяти в /proc/<pid>/maps выявил подходящие writable области в модулях Engine.dll, которые нигде не перегружаются и не перезаписываются динамически. Используя их, можно было без риска сломать игру сохранить маленькие данные о состоянии.
Собрав эти сведения, основной этап заключался в переписывании части функции LoadMap. Для установки флага во время начала загрузки я внедрил инструкция mov по адресу флага до исполнения оригинального кода функции, сопроводив подгонкой JMP в таблице переходов. Для очистки флага перед выходом из функции пришлось выполнять более сложные действия. Возникла необходимость переместить существующие инструкции ret и pop в новую область с NOP, а на их место поставить JMP к новой логике, которая содержала инструкцию обнуления флага и возврата из функции. Таким образом удалось добиться того, что таймер с высокой точностью мог отслеживать факт нахождения игры в состоянии загрузки или в активном игровом процессе.
Это позволило реализовать функцию автопаузы времени во время загрузок – а значит убрать из итогового времени ожидание загрузки уровней, что критично для честного сравнения спидранов. Для дополнения функции авторазделения я реализовал чтение имени текущей карты. Поскольку функция LoadMap возвращает указатель на структуру ULevel, внутри неё на фиксированном смещении хранятся двухбайтовые символы, представляющие имя уровня. В сточке кода я патчил дополнительный блок, который автоматически сохраняет данные по имени карты в заранее выделенную память. Далее таймер читал эту информацию и обновлял интерфейс и логику автоперехода между сплитами.
Несмотря на достижения, были и проблемные моменты. Например, обработка исключений во время загрузки, возникающая в особых случаях с так называемым “Glitchy Save”, которая не проходила через основную ветку LoadMap и поэтому не сбрасывала флаг загрузки. Это приводило к вечной паузе таймера. Было реализовано обходное решение с игнорированием подобных карт, но полное исправление этого требует дальнейших исследований. Также пока не был реализован учёт экранов сохранения, что важно для скоростных прохождений, исключающих время создания сейва.
Сенсор распознавания состояний сохранения достаточно прост – подобно флагу загрузки можно искать флаг сохранения, но пока отсутствует внедрённый код для этого. Ещё один недостаток – периодичность чтения состояния и флагов реализована циклом обновления пользовательского интерфейса с интервалом около 8 миллисекунд. Частые системные вызовы чтения памяти и остановки процесса вносят небольшие, но заметные задержки в производительность игры и воспроизведение. Рассматриваются идеи перехода к событиям на базе сигналов или неблокирующих оповещений от процесса игры, однако это требует значительного переписывания архитектуры. Путь создания таймера в D с внедрением кода в чужой процесс – это плод кропотливого и нетривиального исследования, сочетающего знания о бинарных форматах, системных вызовах Linux, ассемблере, и тонкостях внутреннего устройства конкретной игры.
Тем не менее, реализация показала, что даже без сложной инструментальной поддержки можно построить рабочее решение для авторазделения и исключения времени загрузок на платформе Linux. Язык D обеспечил гибкость, масштабируемость и удобство при кодировании, соединив в себе лучшие качества низкоуровневого программирования и современного высокоуровневого дизайна. Благодаря этому была достигнута эффективная манипуляция памятью, а пользовательский интерфейс оставался отзывчивым и понятным. Кроме технических аспектов, данный проект подчёркивает важность глубокого изучения внутренностей игр и вникания в их архитектуру для создания качественных инструментов скорости. Особенно это актуально для платформ, где готовые решения недоступны или ограничены функциями.
На будущее открыты горизонты для улучшения таймера: добавить поддержку игровых исключений и экранов сохранения, оптимизировать частоту опроса состояния, более продвинутую обработку имен уровней и интеграцию с внешними статистическими и аналитическими инструментами спидрана. Кроме того, можно задуматься о переносе идей и техник на другие игры на Linux, где отсутствует поддержка авторазделения. В итоге создание кастомного таймера для спидрана Deus Ex в Linux на языке D – это пример, как благодаря продуманному инструментарию, системным вызовам и тщательному анализу можно получить прорывные возможности для точного измерения времени прохождений и автоматизации рутинных операций, делая спидран более захватывающим, точным и приятным занятием для игроков.