У игроков на платформе 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!"
.
alexs963
>Одна из них — необходимость устанавливать отдельный клиент Steam для каждой Windows игры из Steam
Что за чушь? Один раз ставится steam и в нём устанавливаются все игры.
xomachine Автор
А если у Вас больше одного префикса?
alexs963
По одной копии на префикс. (Ваш К.О.) Но это далеко не по копии на каждую игру. Я, конечно, не большой игрок, но у меня никогда не возникало необходимости в нескольких префиксах.
xomachine Автор
Вот и я о том же. Соглашусь, что имело место некоторое преувеличение, но в любом случае установка нескольких копий одной программы не очень удобна. Особенно если есть привычка устанавливать каждую игру в отдельный префикс, чтобы исключить взаимное влияние.
alexs963
Для упрощения установки всякого софта есть winetricks
xomachine Автор
Безусловно. Правда, если бы проблемы совсем не существовало, то не было бы таких запросов.
firk
Несколько префиксов это само по себе уже несколько копий системного окружения.