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

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


Истина в Wine


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


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


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


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


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


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

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


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


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


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

[источник]


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


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


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

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

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


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

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


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


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


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


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

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


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

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

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


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


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


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

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


steam_api.nim
const specname {.strdefine.} = "steam_api.spec" # spec файл пригодится во время компиляции, потому принимаем путь к нему через опцию `-d:specname=/path/to/steam_api.spec` с помощью прагмы {.strdefine.} и записываем в константу `specname`.
# Если опция не задана, в константу запишется значение по умолчанию — "steam_api.spec".
{.passL: "'" & specname & "'".} # Также передаем путь к spec файлу линкеру в качестве аргумента.

# Описываем макрос TRACE из заголовочных файлов wine, который поможет нам при отладке
proc trace*(format: cstring)
  {.varargs, importc: "TRACE", header: """#include <stdarg.h>
#include "wine/debug.h"
WINE_DEFAULT_DEBUG_CHANNEL(steam_api);""".}
# Прагма varargs указывает, что после первого аргумента могут быть ещё, прагма importc — как должно выглядеть имя при вызове в Си коде, прагма header — что должно быть помещено в шапку Си файла, где происходит вызов.
# Строго говоря, Nim понятия не имеет что такое TRACE. Зато теперь он знает, как можно вызвать TRACE в коде на Си.

# Эта функция сгенерирована winedump'ом, потому включаем её в промежуточный код на Си почти без изменений.
{.emit:["""
BOOL WINAPI DllMain(HINSTANCE instance, DWORD reason, void *reserved)
{
    """, trace, """("(%p, %u, %p)\n", instance, reason, reserved); // вызываем именно описанный нами макрос, чтобы не ломать зависимости от заголовочных файлов
    switch (reason)
    {
        case DLL_WINE_PREATTACH:
            return FALSE;    /* prefer native version */
        case DLL_PROCESS_ATTACH:
            DisableThreadLibraryCalls(instance);
            NimMain(); // инициализируем сборщик мусора и рантайм Nim
            break;
    }
    return TRUE;
}
"""].}

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

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


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


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

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


Стек до выполнения фрагмента
[...]
[...]
[...]
[адрес возврата] <= ESP
[аргумент 1]
[аргумент 2]
[???]

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

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

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

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


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

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


Читаем файл во время компиляции
from strutils import splitLines, split, parseInt
from tables import initTable, `[]`, `[]=`, pairs, Table
type
  StackState* = tuple 
    # информация о стеке для конкретного метода
    depth: int # глубина стека
    swap: bool # индикатор наличия скрытого аргумента
  Classes* = Table[string, seq[StackState]] ## таблица, которую мы хотим получить: ключи — имена классов (CAdapterSteamYYY0XX), значения — списки глубин стека каждого метода

const cdfile {.strdefine.} = ""
  # по аналогии с прошлым случаем, получаем путь к файлу из опций компилятора

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

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


Процедура создания АСД с декларациями методов-обёрток
static:
  # Ключевое слово static указывает, что работа с переменными происходит во время компиляции.
  var declared: set[uint8] = {} # глубины стека, для которых нужно будет создать псевдометоды
  var swpdeclared: set[uint8] = {} # глубины стека, для которых нужно будет создать псевдометоды со скрытым аргументом

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

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


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

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


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

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


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

Заключение


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


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


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


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

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


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


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

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


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

Комментарии (8)


  1. alexs963
    22.02.2018 20:55

    >Одна из них — необходимость устанавливать отдельный клиент Steam для каждой Windows игры из Steam
    Что за чушь? Один раз ставится steam и в нём устанавливаются все игры.


    1. xomachine Автор
      22.02.2018 20:55

      А если у Вас больше одного префикса?


      1. alexs963
        22.02.2018 21:09

        По одной копии на префикс. (Ваш К.О.) Но это далеко не по копии на каждую игру. Я, конечно, не большой игрок, но у меня никогда не возникало необходимости в нескольких префиксах.


        1. xomachine Автор
          22.02.2018 21:15

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


          1. alexs963
            22.02.2018 21:21

            Для упрощения установки всякого софта есть winetricks


            1. xomachine Автор
              22.02.2018 21:32

              Безусловно. Правда, если бы проблемы совсем не существовало, то не было бы таких запросов.


          1. firk
            22.02.2018 21:24

            Несколько префиксов это само по себе уже несколько копий системного окружения.


  1. VioletGiraffe
    23.02.2018 13:29

    Линукс-геймерам больше нравится играть в запуск игр, чем в сами игры.