У игроков на платформе 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:
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; } """].}
--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 $<глубина стека> # удаляем из стека аргументы и возвращаемся
[адрес возврата в ассемблерный фрагмент] <= 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, поэтому нет нужды в кодогенерации. Например, определение первого метода будет выглядеть так:
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!".
