Пробрасываем вызовы Steam API из Wine в GNU/Linux и обратно с помощью Nim

У игроков на платформе GNU/Linux множество проблем. Одна из них — необходимость устанавливать отдельный клиент Steam для каждой Windows игры из Steam. Ситуация усугубляется необходимостью установки ещё и родного клиента Steam для портированных и кроссплатформенных игр.

Но что если найти способ использовать один клиент для всех игр? За основу можно взять родной клиент, а игры для Windows пусть обращаются к нему так же как, например, к OpenGL или звуковой подсистеме GNU/Linux — средствами Wine. О реализации такого подхода и пойдёт речь далее.

Истина в Wine

Wine умеет работать с библиотеками Windows в двух режимах: стороннем (или native в английской терминологии) и встроенном (builtin). Сторонняя библиотека воспринимается Wine как файл с расширением *.dll, который нужно загрузить в память и работать с ним, как с сущностью Windows. Именно в таком режиме Wine работает со всеми библиотеками, о которых ему ничего не известно. Встроенный режим, подразумевает, что Wine должен обработать обращение к библиотеке особым образом и перенаправить его в заранее созданную обёртку с расширением *.dll.so, которая может обращаться к операционной системе и её библиотекам. Подробнее об этом можно почитать тут.

К счастью, большая часть взаимодействия с клиентом Steam происходит как раз через библиотеку steam_api.dll, а значит, задача сводится к реализации обёртки steam_api.dll.so, которая будет обращаться к аналогу в GNU/Linux — libsteam_api.so.

Создание такой обёртки процесс известный и документированный. Нужно взять исходную библиотеку для Windows, получить для неё spec-файл с помощью winedump, написать реализации всех функций в spec-файле и скомпилировать-слинковать всё это с помощью winegcc. Либо попросить winemaker, чтобы он сделал всю рутинную работу.

Дьявол кроется в деталях

На первый взгляд, задача несложная. Особенно учитывая, что winedump умеет создавать обёртки автоматически при наличии заголовочных файлов исходной библиотеки, а заголовочные файлы публикуются Valve для разработчиков игр на официальном сайте. Итак, после создания обёртки через winedump, включения встроенного режима steam_api.dll в winecfg и компиляции, мы запустили родной Steam, затем саму игру и… Игра падает!

Заглядываем в лог

 trace:steam_api:SteamAPI_RestartAppIfNecessary_ ((uint32 )[спрятан]) trace:steam_api:SteamAPI_RestartAppIfNecessary_ () = (bool )0 trace:steam_api:SteamAPI_Init_ () Setting breakpad minidump AppID = [спрятан] Steam_SetMinidumpSteamID:  Caching Steam ID:  [спрятан] [API loaded no] trace:steam_api:SteamAPI_Init_ () = (bool )1 trace:steam_api:SteamInternal_ContextInit_ ((void *)0x7ee468) trace:steam_api:SteamAPI_GetHSteamPipe_ () trace:steam_api:SteamAPI_GetHSteamPipe_ () = (HSteamPipe )0x1 trace:steam_api:SteamAPI_GetHSteamUser_ () trace:steam_api:SteamAPI_GetHSteamUser_ () = (HSteamUser )0x1 trace:steam_api:SteamAPI_GetHSteamPipe_ () trace:steam_api:SteamAPI_GetHSteamPipe_ () = (HSteamPipe )0x1 trace:steam_api:SteamInternal_CreateInterface_ ((char *)"SteamClient017") wine: Unhandled privileged instruction at address 0x7a3a3c92 (thread 0009), starting debugger... Unhandled exception: privileged instruction in 32-bit code (0x7a3a3c92). 

Примечание: этот лог более информативен, чем формируемый обёрткой, сгенерированной описанным выше способом, но сути проблемы это не меняет.

Судя по логу, наша обёртка работает (!) ровно до момента вызова функции SteamInternal_CreateInterface. Что же с ней не так? После чтения документации и соотнесения её с заголовочными файлами обнаруживаем, что данная функция возвращает указатель на объект класса SteamClient.

Думаю, те, кто знаком с ABI С++ уже поняли в чём подвох. Корень проблемы в соглашениях о вызовах. Стандарт C++ не подразумевает бинарной совместимости программ, собранных разными компиляторами, а в нашем случае игра для windows скомпилирована в MSVC, в то время как родной Steam в GCC. Поскольку все вызовы функций steam_api.dll следуют соглашениям о вызовах языка C, эта проблема не наблюдается. Как только игра получает экземпляр класса SteamClient из родного Steam и пытается вызвать его метод (который следует соглашению С++ thiscall) появляется ошибка. Для исправления проблемы стоит сначала выявить ключевые отличия соглашений для используемых компиляторов.

MSVC GCC
Помещает указатель на объект в регистр ECX. Ожидает найти указатель на объект в стеке на верхней позиции.
Ожидает очистку стека вызываемым методом. Ожидает очистку стека вызывающим кодом.

[источник]

На этом этапе стоит сделать небольшое отступление и упомянуть, что попытки решить задачу, указанную в заголовке уже предпринимались, и даже вполне успешно. Существует проект SteamBridge, использующий две отдельные библиотеки — для Windows и для GNU/Linux. Библиотека для Windows собрана с помощью MSVC и вызывает библиотеку для GNU/Linux, которая подменяется Wine и собрана с помощью GCC по похожей схеме. Проблема методов решена с помощью ассемблерных вставок на стороне библиотеки Windows и обёртки каждого объекта при передаче его в сторону кода MSVC. Это решение несколько избыточно, так как требует дополнительного некроссплатформенного компилятора для сборки и вводит лишнюю сущность, но идея оборачивания возвращаемых объектов здравая. Её-то мы и позаимствуем!

К счастью для нас, Wine уже умеет работать с соглашениями о вызовах. Достаточно объявить метод с атрибутом thiscall. Таким образом, нужно создать обёртки всех методов всех классов, а в реализации методов просто вызывать методы из оригинального класса (ссылка на который хранится в обёртке). Обёртка будет выглядеть так:

class ISteamClient_ {   public:     virtual HSteamPipe CreateSteamPipe() __attribute__((thiscall));     ... // много-много методов   private:     ISteamClient * internal; }

HSteamPipe ISteamClient_::CreateSteamPipe() {   TRACE("((ISteamClient *)%p)\n", this);   HSteamPipe result = this->internal->CreateSteamPipe();   TRACE("() = (HSteamPipe)%p\n", result);   return result; }

Аналогичную операцию, только в обратном направлении нужно провести для классов, передаваемых из MSVC кода в GCC, а именно CCallback и CCallResult. Задача рутинная и неинтересная, потому лучшим решением будет делегировать её скрипту для кодогенерации. После нескольких попыток собрать всё воедино, игра начинает работать.

Фрагмент лога

 trace:steam_api:SteamAPI_RestartAppIfNecessary_ ((uint32 )[спрятан]) trace:steam_api:SteamAPI_RestartAppIfNecessary_ () = (bool )0 trace:steam_api:SteamAPI_Init_ () Setting breakpad minidump AppID = [спрятан] Steam_SetMinidumpSteamID:  Caching Steam ID:  [спрятан] [API loaded no] trace:steam_api:SteamAPI_Init_ () = (bool )1 trace:steam_api:SteamInternal_ContextInit_ ((void *)0x7ee468) trace:steam_api:SteamAPI_GetHSteamPipe_ () trace:steam_api:SteamAPI_GetHSteamPipe_ () = (HSteamPipe )0x1 trace:steam_api:SteamAPI_GetHSteamUser_ () trace:steam_api:SteamAPI_GetHSteamUser_ () = (HSteamUser )0x1 trace:steam_api:SteamAPI_GetHSteamPipe_ () trace:steam_api:SteamAPI_GetHSteamPipe_ () = (HSteamPipe )0x1 trace:steam_api:SteamInternal_CreateInterface_ ((char *)"SteamClient017") trace:steam_api:SteamInternal_CreateInterface_ (): (ISteamClient *)0x7a7a04c8 wrapped as (ISteamClient_ *)0x7c49bc70 trace:steam_api:SteamInternal_CreateInterface_ () = (ISteamClient_ *)0x7c49bc70 trace:steam_api:GetISteamUser ((ISteamClient *)0x7c49bc70, (HSteamUser )0x1, (HSteamPipe )0x1, (char *)"SteamUser019") trace:steam_api:GetISteamUser () = (ISteamUser *)0x7c4bcc40 trace:steam_api:GetISteamFriends ((ISteamClient *)0x7c49bc70, (HSteamUser )0x1, (HSteamPipe )0x1, (char *)"SteamFriends015") trace:steam_api:GetISteamFriends () = (ISteamFriends *)0x7c4b8650 trace:steam_api:GetISteamUtils ((ISteamClient *)0x7c49bc70, (HSteamPipe )0x1, (char *)"SteamUtils008") trace:steam_api:GetISteamUtils () = (ISteamUtils *)0x7c4b7930 trace:steam_api:GetISteamMatchmaking ((ISteamClient *)0x7c49bc70, (HSteamUser )0x1, (HSteamPipe )0x1, (char *)"SteamMatchMaking009") trace:steam_api:GetISteamMatchmaking () = (ISteamMatchmaking *)0x7c4c03c0 trace:steam_api:GetISteamMatchmakingServers ((ISteamClient *)0x7c49bc70, (HSteamUser )0x1, (HSteamPipe )0x1, (char *)"SteamMatchMakingServers002") trace:steam_api:GetISteamMatchmakingServers () = (ISteamMatchmakingServers *)0x7c4b5450 trace:steam_api:GetISteamUserStats ((ISteamClient *)0x7c49bc70, (HSteamUser )0x1, (HSteamPipe )0x1, (char *)"STEAMUSERSTATS_INTERFACE_VERSION011") trace:steam_api:GetISteamUserStats () = (ISteamUserStats *)0x7c4b5e10 trace:steam_api:GetISteamApps ((ISteamClient *)0x7c49bc70, (HSteamUser )0x1, (HSteamPipe )0x1, (char *)"STEAMAPPS_INTERFACE_VERSION008") trace:steam_api:GetISteamApps () = (ISteamApps *)0x7c4b73a0 trace:steam_api:GetISteamNetworking ((ISteamClient *)0x7c49bc70, (HSteamUser )0x1, (HSteamPipe )0x1, (char *)"SteamNetworking005") trace:steam_api:GetISteamNetworking () = (ISteamNetworking *)0x7c49cd40 trace:steam_api:GetISteamRemoteStorage ((ISteamClient *)0x7c49bc70, (HSteamUser )0x1, (HSteamPipe )0x1, (char *)"STEAMREMOTESTORAGE_INTERFACE_VERSION014") trace:steam_api:GetISteamRemoteStorage () = (ISteamRemoteStorage *)0x7c4c1610 trace:steam_api:GetISteamScreenshots ((ISteamClient *)0x7c49bc70, (HSteamUser )0x1, (HSteamPipe )0x1, (char *)"STEAMSCREENSHOTS_INTERFACE_VERSION003") trace:steam_api:GetISteamScreenshots () = (ISteamScreenshots *)0x7c4b70b0 trace:steam_api:GetISteamHTTP ((ISteamClient *)0x7c49bc70, (HSteamUser )0x1, (HSteamPipe )0x1, (char *)"STEAMHTTP_INTERFACE_VERSION002") trace:steam_api:GetISteamHTTP () = (ISteamHTTP *)0x7c4b5c50 trace:steam_api:GetISteamUnifiedMessages ((ISteamClient *)0x7c49bc70, (HSteamUser )0x1, (HSteamPipe )0x1, (char *)"STEAMUNIFIEDMESSAGES_INTERFACE_VERSION001") trace:steam_api:GetISteamUnifiedMessages () = (ISteamUnifiedMessages *)0x7c49e680 trace:steam_api:GetISteamController ((ISteamClient *)0x7c49bc70, (HSteamUser )0x1, (HSteamPipe )0x1, (char *)"SteamController005") trace:steam_api:GetISteamController () = (ISteamController *)0x7c49bfd0 trace:steam_api:GetISteamUGC ((ISteamClient *)0x7c49bc70, (HSteamUser )0x1, (HSteamPipe )0x1, (char *)"STEAMUGC_INTERFACE_VERSION009") trace:steam_api:GetISteamUGC () = (ISteamUGC *)0x7c49cad0 trace:steam_api:GetISteamAppList ((ISteamClient *)0x7c49bc70, (HSteamUser )0x1, (HSteamPipe )0x1, (char *)"STEAMAPPLIST_INTERFACE_VERSION001") trace:steam_api:GetISteamAppList () = (ISteamAppList *)0x7c49c450 trace:steam_api:GetISteamMusic ((ISteamClient *)0x7c49bc70, (HSteamUser )0x1, (HSteamPipe )0x1, (char *)"STEAMMUSIC_INTERFACE_VERSION001") trace:steam_api:GetISteamMusic () = (ISteamMusic *)0x7c49cbf0 trace:steam_api:GetISteamMusicRemote ((ISteamClient *)0x7c49bc70, (HSteamUser )0x1, (HSteamPipe )0x1, (char *)"STEAMMUSICREMOTE_INTERFACE_VERSION001") trace:steam_api:GetISteamMusicRemote () = (ISteamMusicRemote *)0x7c49e710 trace:steam_api:GetISteamHTMLSurface ((ISteamClient *)0x7c49bc70, (HSteamUser )0x1, (HSteamPipe )0x1, (char *)"STEAMHTMLSURFACE_INTERFACE_VERSION_003") trace:steam_api:GetISteamHTMLSurface () = (ISteamHTMLSurface *)0x7c49ccb0 trace:steam_api:GetISteamInventory ((ISteamClient *)0x7c49bc70, (HSteamUser )0x1, (HSteamPipe )0x1, (char *)"STEAMINVENTORY_INTERFACE_V001") trace:steam_api:GetISteamInventory () = (ISteamInventory *)0x7c49d0c0 trace:steam_api:GetISteamVideo ((ISteamClient *)0x7c49bc70, (HSteamUser )0x1, (HSteamPipe )0x1, (char *)"STEAMVIDEO_INTERFACE_V001") trace:steam_api:GetISteamVideo () = (ISteamVideo *)0x7c49cb60 trace:steam_api:SetOverlayNotificationPosition ((ISteamUtils *)0x7c4b7930, (ENotificationPosition )0x2) trace:steam_api:SteamInternal_ContextInit_ ((void *)0x7ee468) trace:steam_api:SetWarningMessageHook ((ISteamUtils *)0x7c4b7930, (SteamAPIWarningMessageHook_t )0x52ebb0) 

Казалось бы: вот и сказочке конец? А вот и нет!

Добро пожаловать в версионный ад!

Очень скоро выясняется, что наша конструкция полностью жизнеспособна только для игр, собранных с использованием тех же заголовочных файлов, что есть у нас в наличии. А в наличии у нас только последняя версия Steam API, другие версии Valve не публикует (да и эту-то дали под закрытой лицензией). С другой стороны, Steam у нас тоже последней версии, но это не мешает ему работать со старыми версиями Steam API. Как ему это удаётся?

Ответ скрыт в этой строчке лога: trace:steam_api:SteamInternal_CreateInterface_ ((char *)"SteamClient017"). Оказывается, в клиенте хранится информация о всех классах всех версий SteamAPI, а steam_api.dll лишь запрашивает у клиента экземпляр нужного класса нужной версии. Осталось только найти, где именно она хранится. Для начала попробуем подход «в лоб»: попробуем найти строку «SteamClient016» в libsteam_api.so. Почему не «SteamClient017»? Потому что нам нужно найти местонахождение всех версий классов Steam API, а не только той версии, к которой относится libsteam_api.so.

$ grep "SteamClient017" libsteam_api.so  Двоичный файл libsteam_api.so совпадает $ grep "SteamClient016" libsteam_api.so  $

Похоже, в libsteam_api.so нет ничего похожего. Тогда попробуем пройтись по всем библиотекам клиента Steam.

$ grep "SteamClient017" *.so Двоичный файл steamclient.so совпадает Двоичный файл steamui.so совпадает $ grep "SteamClient016" *.so Двоичный файл steamclient.so совпадает $

А вот и то, что нам нужно! Занавешиваем икону Гейба Ньюэлла, если имеется, и открываем steamclient.so в IDA. Быстрый поиск по ключевому слову выдает любопытный набор строк: CAdapterSteamClient0XX, где XX — номер версии. Что ещё более любопытно, в файле имеются строки CAdapterSteamYYYY0XX, где XX — всё так же номер версии, а YYYY — имя интерфейса Steam API для всех остальных интерфейсов. Анализ перекрёстных ссылок позволяет без особых усилий найти таблицу виртуальных методов для каждого из классов с такими названиями. Таким образом, суммарная схема для каждого класса будет выглядеть так:

Таблица методов найдена, вот только у нас совсем нет никакой информации о сигнатурах этих методов. Но и эта проблема оказалась решаемой с помощью подсчёта максимальной глубины стека, на которую метод пытается получить доступ. Так можно сделать утилиту, которая будет получать на вход steamclient.so, а на выходе формировать список классов всех версий, а так же их методов. Осталось только на основе этого списка сгенерировать код обёртки классов для преобразования методов. Задача не выглядит простой особенно учитывая, что сами сигнатуры методов нам по-прежнему не известны, мы знаем лишь глубину стека, на которой заканчиваются аргументы метода. Ситуация усугубляется особенностями возвращения некоторых структур по значению, а именно наличием скрытого аргумента-указателя на память, куда должна быть записана структура. Этот указатель во всех соглашениях о вызовах извлекается из стека вызываемой функцией, потому его легко вычислить по инструкции ret $4 в методах из steamclient.so. Но даже так, объём нетривиальной кодогенерации огромен.

Явление героя

К любому новому или просто не слишком популярному языку программирования в первую очередь возникает вопрос о его нише. Nim — не исключение. Его часто критикуют за попытку «усидеть на всех стульях сразу», подразумевая наполненность большим количеством особенностей при отсутствии одного чёткого направления развития. Среди таких особенностей можно особо выделить две:

  • компиляция в Си и, как следствие, кроссплатформенность;
  • отличная поддержка метапрограммирования (один и тот же язык для run-time и compile-time кода, прямая манипуляция АСД).

Именно это сочетание в результате и позволит сделать процесс написания обёртки безболезненным.
Для начала создадим основной файл steam_api.nim и файл с опциями компиляции steam_api.nims:

steam_api.nim

const specname {.strdefine.} = "steam_api.spec" # spec файл пригодится во время компиляции, потому принимаем путь к нему через опцию `-d:specname=/path/to/steam_api.spec` с помощью прагмы {.strdefine.} и записываем в константу `specname`. # Если опция не задана, в константу запишется значение по умолчанию — "steam_api.spec". {.passL: "'" & specname & "'".} # Также передаем путь к spec файлу линкеру в качестве аргумента.  # Описываем макрос TRACE из заголовочных файлов wine, который поможет нам при отладке proc trace*(format: cstring)   {.varargs, importc: "TRACE", header: """#include <stdarg.h> #include "wine/debug.h" WINE_DEFAULT_DEBUG_CHANNEL(steam_api);""".} # Прагма varargs указывает, что после первого аргумента могут быть ещё, прагма importc — как должно выглядеть имя при вызове в Си коде, прагма header — что должно быть помещено в шапку Си файла, где происходит вызов. # Строго говоря, Nim понятия не имеет что такое TRACE. Зато теперь он знает, как можно вызвать TRACE в коде на Си.  # Эта функция сгенерирована winedump'ом, потому включаем её в промежуточный код на Си почти без изменений. {.emit:[""" BOOL WINAPI DllMain(HINSTANCE instance, DWORD reason, void *reserved) {     """, trace, """("(%p, %u, %p)\n", instance, reason, reserved); // вызываем именно описанный нами макрос, чтобы не ломать зависимости от заголовочных файлов     switch (reason)     {         case DLL_WINE_PREATTACH:             return FALSE;    /* prefer native version */         case DLL_PROCESS_ATTACH:             DisableThreadLibraryCalls(instance);             NimMain(); // инициализируем сборщик мусора и рантайм Nim             break;     }     return TRUE; } """].}

steam_api.nims

--app:lib # мы создаём библиотеку steam_api.dll.so, а не исполняемый файл --passL:"-mno-cygwin" # несколько специальных опций передаём winegcc напрямую --passC:"-mno-cygwin" # на самом деле это вовсе не опция, а макрос `--`, который эмулирует поведение опций компилятора --passC:"-D__WINESRC__" # а сам файл написан на подмножестве языка Nim --os:windows # хотя библиотека компилируется в linux, wine предоставляет нам функции WinAPI --noMain # Мы создали свою функцию `DllMain`, поэтому не нужно, чтобы Nim создал ещё одну --cc:gcc # явно указываем семейство компилятора C # Дальше придётся использовать `switch`, так как макрос `--` не поддерживает точки в имени опции switch("gcc.exe", "/usr/bin/winegcc") # а также путь к самому компилятору и линкеру switch("gcc.linkerexe", "/usr/bin/winegcc") # я уже говорил что `switch` и `--` эквивалентны?

Выглядит не очень-то и просто, но это лишь по причине того, что мы замахнулись на многое сразу. Здесь и кросскомпиляция, и импорт функций из заголовочных файлов Си, и особенности компиляции под Wine… Несмотря на кажущуюся сложность, ничего сложного не произошло, мы просто напрямую внедрили некоторые части исходного кода на Си, о которых Nim ничего не знает, и знать не может, а заодно описали для Nim как вызывать макрос TRACE из заголовочных файлов Wine (про сами эти файлы тоже рассказали).

Теперь перейдём к самому вкусному — макросам и кодогенерации. Поскольку у нас нет полной информации о сигнатурах методов, мы будем эмулировать экземпляры классов из кода на Си, благо нам нужно эмулировать только виртуальную таблицу методов. Итак, пусть у нас есть файл, в котором описаны методы и классы Steam API следующим образом:

 !CAdapterSteamYYY0XX [+]<глубина стека метода 1> [+]<глубина стека метода 2> ... 

Знак + опционален и будет служить индикатором скрытого аргумента.
Этот файл можно получить, анализируя steamclient.so. Из него должна получиться таблица. Ключами к ней будут строки вида CAdapterSteamYYYY0XX, а значениями — массив ссылок на функции, вызывающие соответствующие методы в объекте, который является полем структуры, переданной в них неявно, через регистр ECX. Писать всё это на ассемблере не очень удобно, особенно учитывая, что неплохо было бы добавить какое-нибудь журналирование, поэтому выделим минимальный ассемблерный фрагмент:

Стек до выполнения фрагмента

 [...] [...] [...] [адрес возврата] <= ESP [аргумент 1] [аргумент 2] [???] 

push %ecx # помещаем в стек указатель на объект (он станет вторым аргументом) push $<порядковый номер метода в таблице> # помещаем в стек номер метода (он будет самым первым аргументом) # остальные аргументы сдвинутся на 3 (два помещённых в стек и адрес возврата) call <функция Nim> # вызываем функцию, написанную на Nim add $0x4, %esp # убираем из стека номер метода pop %ecx # извлекаем указатель на объект ret $<глубина стека> # удаляем из стека аргументы и возвращаемся

Стек после вызова функции Nim

 [адрес возврата в ассемблерный фрагмент] <= ESP [номер метода] [указатель на объект = %ecx] [адрес возврата] [аргумент 1] [аргумент 2] [???] 

Стек после возврата из фрагмента

 [адрес возврата в ассемблерный фрагмент] [номер метода] [указатель на объект = %ecx] [адрес возврата] [аргумент 1] [аргумент 2] [???] <= ESP 

Осталось сгенерировать обозначенные функции Nim. Нужно сгенерировать по одной функции для каждой глубины стека встреченной в файле и ещё по одной для вызовов со скрытым аргументом. Далее будем называть эти функции псевдометодами для краткости.

Вот пример такой функции

proc pseudoMethod4(methodNo: uint32, obj: ptr WrappedObject, retAddress: pointer, argument1: pointer) : uint64 {.cdecl.} =   # Название метода pseudoMethod<глубина стека>   # methodNo - порядковый номер метода в виртуальной таблице начиная с 0   # obj - указатель на обертку объекта   # retAddress - адрес возврата в код игры (не используется)   # argument1 - аргумент, передаваемый в метод   # возвращаем uint64, так как наверняка неизвестно, будет ли возвращено 64 битное значение в регистрах EAX и EDX или 32 битное в EAX.   # прагма cdecl говорит компилятору, что он должен следовать соглашениям о вызовах Си     trace("Method No %d was called for obj=%p and return to %p\n",           methodNo, obj, retAddress)     trace("(%p)\n", argument1)     trace("Origin = %p\n", obj.origin)     let vtableaddr = obj.origin.vtable     trace("Origins VTable = %p\n", vtableaddr) # просто выводим всю информацию о методе для отладки     let maddr = cast[ptr proc(obj: pointer argument1: pointer): uint64](cast[uint32](vtableaddr) + methodNo*4) # вычисляем положение адреса оригинального метода     trace("Method address to call: %p\n", maddr)     let themethod = maddr[] # получаем адрес оригинального метода     trace("Method to call: %p\n", themethod)     let res = themethod(obj.origin, argument1) # вызываем оригинальный метод (соглашения о вызовах GCC)     trace("Result = %p\n", res)     return wrapIfNecessary(res) # если результат - указатель на объект, то оборачиваем его и возвращаем обёртку.

Оставим за скобками реализацию функции wrapIfNecessary и перейдём к описанию кода, который генерирует описанные выше фрагменты. Сначала прочитаем файл, в котором хранятся описания классов. Путь к файлу мы получим так же, как и путь к spec-файлу — через опцию компилятора.

Читаем файл во время компиляции

from strutils import splitLines, split, parseInt from tables import initTable, `[]`, `[]=`, pairs, Table type   StackState* = tuple      # информация о стеке для конкретного метода     depth: int # глубина стека     swap: bool # индикатор наличия скрытого аргумента   Classes* = Table[string, seq[StackState]] ## таблица, которую мы хотим получить: ключи — имена классов (CAdapterSteamYYY0XX), значения — списки глубин стека каждого метода  const cdfile {.strdefine.} = ""   # по аналогии с прошлым случаем, получаем путь к файлу из опций компилятора  proc readClasses(): Classes {.compileTime.} =   # прагма compileTime явно указывает компилятору, что не нужно генерировать код для этой функции   result = initTable[string, seq[StackState]]() # result — неявная переменная, которая будет возвращена в конце функции   let filedata = slurp(cdfile) # во время компиляции файл читается функцией `slurp`, в то время как обычные функции работы с файлами недоступны   for line in filedata.splitLines():     if line.len == 0:       continue     elif line[0] == '!':       let curstr = line[1..^1] # подстрока с первого по последний символ       result[curstr] = newSeq[StackState]()     else:       let depth = parseInt(line)       let swap = line[0] == '+' # в качестве индикатора скрытого аргумента служит знак "+" перед глубиной стека       # он не влияет на распознавание числа и очень легко проверяется       result[curstr].add((depth: depth, swap: swap)) # Именованный кортеж не требует особого конструктора с именем типа   # возврата нет, так как в result и так записано возвращаемое значение

Теперь мы получили таблицу классов. Поскольку функция readClasses не использует ничего, возможного только во время выполнения, мы смело можем вычислить её во время компиляции и записать результат в константу: const classes = readClasses(). Составим таблицу методов-обёрток, состоящих из ассемблерных вставок, описанных выше.

Процедура создания АСД с декларациями методов-обёрток

static:   # Ключевое слово static указывает, что работа с переменными происходит во время компиляции.   var declared: set[uint8] = {} # глубины стека, для которых нужно будет создать псевдометоды   var swpdeclared: set[uint8] = {} # глубины стека, для которых нужно будет создать псевдометоды со скрытым аргументом  proc eachMethod(k: string, methods: seq[StackState], sink: NimNode): NimNode {.compileTime.} =   # создаёт декларацию функции и присваивает её `k`тому элементу в таблице с идентификатором `sink`   # NimNode - любой элемент АСД. В нашем случае это идентификатор на входе и список выражений на выходе.   result = newStmtList() # пустой список выражений языка   let kString = newStrLitNode k # превращение строки в узел АСД, означающий строку   # Unified Call Syntax позволяет записывать вызовы функций как душе угодно, конкретно верхний эквивалентен newStrLitNode(k), k.newStrLitNode() и k.newStrLitNode (стиль изменён для демострации)   result.add quote do: # quote - особый макрос, создающий АСД для участка кода, переданного ему в качестве аргумента, а `do` позволяет превратить в аргумент код под ним     `sink`[`kString`] = newSeq[MethodProc](2) # всё, что в кавычках будет подставлено в АСД без изменений   for i, v in methods.pairs():     if v.swap: # подсчёт псевдометодов, которые предстоит создать       swpdeclared.incl(v.depth.uint8) # неявные преобразования типов не допускаются     else:       declared.incl(v.depth.uint8)     # Уже знакомая нам ассемблерная вставка в виде строки с комментариями.     # Необходимые значения вклеиваются в неё оператором конкатенации `&`.     # Тройные кавычки ведут себя также как в питоне.     let asmcode = """     push %ecx # помещаем в стек указатель на объект     push $0x""" & i.toHex & """ # затем номер метода в виртуальной таблице     call `pseudoMethod""" & $v.depth & (if v.swap: "S" else: "") & #конструкции if-elif-else и case-of-else могут быть выражениями возвращающими результат       """` # вызываем псевдометод     add $0x4, %esp # убираем из стека номер метода     pop %ecx # возвращаем указатель на объект в регистр ECX и чистим от него стек     ret $""" & $(v.depth-4) &  """ # чистим стек от остальных аргументов и возвращаемся """     var tstr = newNimNode(nnkTripleStrLit) # nnkTripleStrLit это тип узла АСД для строки в тройных кавычках     tstr.strVal = asmcode # превращаем строку в узел АСД эквивалентный этой строке     let asmstmt = newTree(nnkAsmStmt, newEmptyNode(), tstr) # а затем в узел АСД эквивалентный выражению `asm """<код>"""`     let methodname = newIdentNode("m" & k & $i) # создаём идентификатор метода как `m<имя класса><номер метода>`     result.add quote do: # вклеиваем в шаблон декларации функции и добавляем полученное АСД к общему списку       proc `methodname` () {.asmNoStackFrame, noReturn.} = # декларация функции         # прагма asmNoStackFrame должна указать компилятору, не создавать новый фрейм в стеке         # прагма noReturn говорит компилятору, что возврат сделан вручную и генерировать для этого код не нужно         `asmstmt`     # присваивание     add(`sink`[`kString`], `methodname`) # макросу quote не всегда удаётся правильно понять конструкцию с вклеенными кусками АСД, потому иногда приходится призывать на помощь UCS и видоизменить вызов

По полученным спискам строим псевдометоды. Процесс перебора списков оставлен за кадром. Также стоит отметить, что все процедуры, использованные нами — обычные функции Nim, оперирующие АСД и вызываемые из тела макроса (который тоже опущен). Магия интерпретации созданных АСД происходит при выходе из тела макроса.

Процедура создания псевдометодов

proc makePseudoMethod(stack: uint8, swp: bool): NimNode {.compileTime.} =   ## Создаёт АСД с декларацией псевдометода.   result = newProc(newIdentNode("pseudoMethod" & $stack &                                 (if swp:"S" else: ""))) # новая декларация пустой функции с именем "pseudoMethod<глубина стека>[S]"   # подход с `quote` тут не работает, так как аргументы генерируются динамически   result.addPragma(newIdentNode("cdecl")) # добавляем {.cdecl.}   let nargs = max(int(stack div 4) - 1 - int(swp), 0) # число реальных аргументов за вычетом самого объекта и скрытого аргумента, если он есть   let justargs = genArgs(nargs) # эта функция опущена, её результат - массив деклараций аргументов функции от "argument1: uint32" до "argument<nargs>: uint32"   let origin = newIdentNode("origin")   let rmethod = newIdentNode("rmethod")   var mcall = genCall("rmethod", nargs) # эта функция тоже опущена, её результат - АСД вызова "rmethod(argument1, ... , argument<nargs>)"   mcall.insert(1, origin) # вставка первым аргументом идентификатора оригинального объекта   var argseq = @[ # Аргументы самого псевдометода     newIdentNode("uint64"), # возвращаемое значение     newIdentDefs(newIdentNode("methodNo"), newIdentNode("uint32")),       # порядковый номер метода     newIdentDefs(newIdentNode("obj"), newIdentNode("uint32")),       # ссылка на объект (тип изменён на uint32 для простоты восприятия)     newIdentDefs(newIdentNode("retAddress"), newIdentNode("uint32")),       # адрес возврата   ]   if swp:     # если есть скрытый аргумент - добавляем его     argseq.add(newIdentDefs(newIdentNode("hidden"), newIdentNode("pointer")))   # остальные аргументы добавляем в конец   argseq &= justargs[1..^1]   var originargs = @[ # Аргументы для декларации оригинального метода     newIdentNode("uint64"),     newIdentDefs(newIdentNode("obj"), newIdentNode("uint32")),   ] & justargs[1..^1]   let procty = newTree(nnkProcTy, newTree(nnkFormalParams, originargs),                        newTree(nnkPragma, newIdentNode("cdecl"))) # сама декларация оригинального метода   let args = newTree(nnkFormalParams, argseq)   result[3] = args # подставляем аргументы в декларацию псевдометода   let tracecall = genTraceCall(nargs) # реализация опущена для простоты, результат - вызов trace со всеми аргументами, переданными в псевдометод   result.body = quote do: # подстановка тела функции     trace("Method No %d was called for obj=%p and return to %p\n",           methodNo, obj, retAddress)     `tracecall`     let wclass = cast[ptr WrappedClass](obj) # цена нашего упрощения декларации - необходимость преобразования `uint32` в `ptr WrappedClass`     let `origin` = cast[uint32](wclass.origin)     trace("Origin = %p\n", `origin`)     let vtableaddr = wclass.origin.vtable     trace("Origins VTable = %p\n", vtableaddr)     let maddr = cast[ptr `procty`](cast[uint32](vtableaddr) + shift*4)     trace("Method address to call: %p\n", maddr)     let `rmethod` = maddr[]     trace("Method to call: %p\n", `rmethod`)   if swp:     # для случая скрытого аргумента нужна ещё одна ассемблерная вставка, тут она показана не будет     let asmcall = genAsmHiddenCall("rmethod", "origin", nargs) # вставка меняет местами скрытый аргумент и указатель на объект, а также исправляет стек так, что скрытый аргумент перестаёт быть скрытым     result.body.add quote do:       trace("Hidden before = %p (%p) \n", hidden, cast[ptr cint](hidden)[])       `asmcall` # вызов происходит внутри вставки       trace("Hidden result = %p (%p) \n", hidden, cast[ptr cint](hidden)[])       return cast[uint64](hidden)     # зато для случая скрытого аргумента не нужно выполнять проверку необходимости обёртки, заранее известно, что возвращаемое значение не является указателем на объект   else:     # добавляем АСД самого вызова и проверку необходимости обёртки     result.body.add quote do:       let res = `mcall`       trace("Result = %p\n", res)       return wrapIfNecessary(res) # реализация `wrapIfNecessary` в эту статью не поместилась

Самая сложная часть позади. Сложность её обусловлена необходимостью формирования и вставки динамического списка аргументов в несколько ключевых точек декларации псевдометода. Здесь не работает простой подход с шаблоном и подстановкой через quote, поэтому приходится собирать узлы АСД один за другим, что негативно сказывается на объеме и читаемости кода. Осталось написать сам макрос, из которого будут вызываться наши генераторы АСД.

Макрос

macro makeTableOfVTables(sink: untyped): untyped =   # создаёт таблицу с массивами виртуальных методов каждого класса   # `sink` - переменная-назначение, куда всё будет записано.   result = newStmtList() # пустой список выражений   result.add quote do: # `sink` в аргументах макроса указан как untyped, но в теле макроса он чудесным образом превращается в узел АСД, то есть имеет тип NimNode     `sink` = initTable[string, seq[MethodProc]]() # создаём новую таблицу   let classes = readClasses() # та самая функция readClasses, которой мы разбирали файл во время компиляции   for k, v in classes.pairs:     result.add(eachMethod(k, v, sink)) # сначала создаём методы-обёртки   for i in declared: # напомню, что `declared` это глобальная переменная времени компиляции, по совместительству множество, которое мы определили и наполнили в eachMethod ранее.     result.insert(0, makePseudoMethod(i, false)) # псевдометоды вставляем до самих методов, поскольку Nim, как и Си, чувствителен к порядку определения функций   for i in swpdeclared:     result.insert(0, makePseudoMethod(i, true))   when declared(debug): # если компилятору передан флаг `-d:debug`, выводим АСД в виде кода в stdout прямо во время компиляции,     echo(result.repr) # на случай если нужно будет посмотреть, как выглядит сгенерированный код   # магия макроса превращает наш `result` из NimNode обратно в `untyped`, то есть в код # и вызов макроса. var vtables: Table[string, seq[MethodProc]] makeTableOfVTables(vtables)

Похожим образом создаются объявления основных функций steam_api.dll. Для проброса вызовов обратно из GNU/Linux в игру форма уже известна и едина для всех версий Steam API, поэтому нет нужды в кодогенерации. Например, определение первого метода будет выглядеть так:

Первый метод класса CCallback

proc run(obj: ptr WrappedCallback, p: pointer) {.cdecl.} =   # первый виртуальный метод класса CCallback.   trace("[%p](%p)\n", obj, p)   let originRun = (obj.origin.vtable + 0)[] # `+` определён отдельно для указателя и числа, чтобы избежать большого количества преобразований типов   let originObj = obj.origin   asm """     mov %[obj], %%ecx # Метод игры ожидает увидеть указатель на объект в регистре ECX     mov %%esp, %%edi # ESP сохраняем в EDI, т.к. он не меняется при вызове     push %[p] # Помещаем аргумент в стек     call %[mcall] # вызываем метод     mov %%edi, %%esp # восстанавливаем стек     ::[obj]"g"(`originObj`), [p]"g"(`p`), [mcall]"g"(`originRun`)     :"eax", "edi", "ecx", "cc" """

Заключение

Итак, мы рассмотрели основные ключевые точки, позволяющие сгенерировать обёртку для Steam API во время компиляции. Какими бы сложными они не казались, такой подход, несомненно, выигрывает у ручного написания нескольких сотен однотипных методов. Nim написал все эти методы за нас. Кто-то может спросить: «А что там с отладкой всего этого ужаса?». Вопрос отладки кода времени компиляции действительно сложен. Единственное средство — это старые добрые отладочные сообщения echo (аналог print в Nim). К счастью в Nim есть функции repr и treeRepr, которые превращают АСД в строку кода и строку со структурной схемой узлов соответственно, что сильно упрощает отладку.

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

Возможно, статья покажется слишком сумбурной, поскольку достаточно непросто описать сложную задачу и её решение, в которых язык раскрывается на полную мощность, простым и лаконичным образом. К сожалению, в рамках этой статьи не удалось описать ещё несколько аспектов, а именно:

  • функцию wrapIfNeccessary и механизм определения имени объекта по указателю;
  • формирование класса-обёртки на основе описанных методов;
  • взаимодействие со Steam для загрузки игры;
  • подробности реализации обёрток функций steam_api.dll (в статье речь шла только о виртуальных методах);
  • утилиты для анализа steamclient.so и libsteam_api.so, эмуляция поведения стека;
  • подводные камни и проблемы, которые возникли при поиске описанных в статье решений (сборщик мусора, игнорирование прагмы asmNoStackFrame, старые версии компилятора).

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

Рабочее решение обозначенной в заголовке проблемы представлено в репозитории на github:

  • в ветке master реализация без использования Nim и хорошо работающая только с одной версией Steam API;
  • в ветке devel реализация с использованием Nim, о которой шла речь во второй половине статьи.

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

Надеюсь, статья вызовет дополнительный интерес к языку программирования Nim и покажет читателям, что на нём можно писать нечто более сложное, чем echo "Hello, world!".

FavoriteLoadingДобавить в избранное

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *