Пробрасываем вызовы 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
:
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
#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: uint32"
let origin = newIdentNode("origin")
let rmethod = newIdentNode("rmethod")
var mcall = genCall("rmethod", nargs) # эта функция тоже опущена, её результат - АСД вызова "rmethod(argument1, ... , argument)"
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]]
eachTable(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!"
.