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

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

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


    Истина в Wine


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


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


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


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


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


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

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


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


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


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

    [источник]


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


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


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

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

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


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

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


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


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


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


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

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


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

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

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


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


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


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

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


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

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

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


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


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

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


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

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

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

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

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


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

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


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

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


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

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


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

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


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

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


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

    Заключение


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


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


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


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

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


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


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

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


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

    Поделиться публикацией
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 10
    • 0
      >Одна из них — необходимость устанавливать отдельный клиент Steam для каждой Windows игры из Steam
      Что за чушь? Один раз ставится steam и в нём устанавливаются все игры.
      • +1
        А если у Вас больше одного префикса?
        • 0
          По одной копии на префикс. (Ваш К.О.) Но это далеко не по копии на каждую игру. Я, конечно, не большой игрок, но у меня никогда не возникало необходимости в нескольких префиксах.
          • 0
            Вот и я о том же. Соглашусь, что имело место некоторое преувеличение, но в любом случае установка нескольких копий одной программы не очень удобна. Особенно если есть привычка устанавливать каждую игру в отдельный префикс, чтобы исключить взаимное влияние.
            • 0
              Для упрощения установки всякого софта есть winetricks
            • 0
              Несколько префиксов это само по себе уже несколько копий системного окружения.
      • +8
        Линукс-геймерам больше нравится играть в запуск игр, чем в сами игры.
        • 0
          Годно! Спасибо за ваш труд и вклад в обратную совместимость наследия ПО! Думаю данный материал стоило бы перевести на английский язык и показать его сообществу (как касательно wine, так и nim, отправить на соответствующие email-рассылки), такового ещё не имеется? Я думаю не исключено также что Valve возможно будет заинтересована по части развития своего SteamOS.
          • 0

            Я выкладывал ссылку на github проекта на reddit и один раз это делали за меня. Это, конечно, не профильные сообщества, но хоть какие-то попытки донести информацию. Если у кого-нибудь возникнет желание сделать перевод статьи и опубликовать его (со ссылкой на оригинал, естественно) — я не буду против. Мой английский пока ещё слишком беден для такого, как мне кажется.


            К тому же, для реализации библиотеки была проведена обратная разработка, что прямо запрещено соглашением подписчика Steam. Не думаю, что они будут в восторге. Похожая разработка — SteamBridge (которая упоминается в статье) благополучно игнорировалась службой поддержки Valve. Они даже отказывались ответить на вопрос о легальности подобного подхода. Насколько я понял из ответов на запросы в багтрекере Steam для Linux, политика Valve в отношении Wine весьма однозначна: они не слишком стремятся поддерживать запуск игр для Windows из Steam в Linux в таком виде. Тем более, что о возможности собирать steam_api.dll.so для Wine им рассказывали в комментариях (а при наличии исходных кодов им это сделать намного проще).

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое