Как стать автором
Обновить

Играем с KAT Walk C2. (часть 1: собственно, играем)

Уровень сложностиСредний
Время на прочтение22 мин
Количество просмотров2.3K

У меня есть дурная привычка: я играю в игры. Впрочем, понятие "играю в игры" довольно своеобразное.

Уже несколько лет как я обзавелся VR платформой от KAT VR.

Согласно её внутренней статистики, я прошел на ней около 30 километров и сделал 40к шагов. Статистика, конечно, врёт и сильно, было гораздо, гораздо больше.

Однако же да, на ней я играл гораздо меньше времени чем с ней. Впрочем, обо всём по порядку.

Что такое VR платформа

VR платформа это просто способ остаться на месте но при этом куда-то идти. На рынке есть несколько решений для "VR locomotion", начиная от простых "как только дошли до границы пространства -- развернулся на 180 в реале и еще раз на 180 в игре", продолжая подошвами для бега на стуле и заканчивая VR платформами.

Платформы есть двигающие игрока, например HoloTile от Disney, либо то, что показали в Mythic Quest или в форме "мототапок" FreeAim. Другая крайность, это платформы, где хорошо зафиксированный игрок в моторах не нуждается. Моя как раз из последней категории. Собственно, сейчас на рынке доступных для обывателя только они и есть.

Фактически, рынок готовых решений состоит из KAT Walk C2 / C2Core и... Всё. Очень скоро еще будет Virtuix Omni One, которые наконец-то занялись массовым производством, но всё еще выслали только несколько первых образцов самым ранним инвесторам, так что говорить о плюсах и минусах их по сравнению с KAT Walk C2 можно, но преждевременно.

Платформа KAT Walk C2 / C2Core

Моя KAT Walk C2+ в её естественной среде обитания
Моя KAT Walk C2+ в её естественной среде обитания

Итак, платформа для игры представляет собой, вы не поверите, платформу. Это пластиковая чашка, в которой предстоит барахтаться. К платформе на мощном и крепком стабильном подшипнике приделан столб, к которому игрок присоединяется поясом. Столб свободно вращается на 360 градусов, таким образом, игрок свободен в повороте. Пояс закреплен на подвижном креплении, обеспечивающем некоторую свободу вверх и вниз, что позволяет приседать и даже подпрыгивать. У моей, C2, вертикальный запас небольшой, и с моим ростом у меня выбор -- или прыжки или приседание. Я выбрал приседание, так как мой стиль игры -- обойти все углы :). У модели C2Core сделали вертикальный ход гораздо больше, что позволяет и приседать и подпрыгивать и при этом не надо фиксировать на рост игрока. Из минусов -- на C2 можно просто повиснуть на поясе (например, для игр "под водой") на C2Core так сделать нельзя. Впрочем, игр, где мне бы это понадобилось, я не знаю.

Пояс это хорошо, но что дальше? Дальше -- специальные ботинки, в которые вкладываются сенсоры. В прошлом поколении платформы, это были инерциальные сенсоры, в C2/C2Core -- это сенсоры как в оптических мышах. Третий сенсор встроен в столб позади игрока, он определяет положение тела.

Все три сенсора подлежат зарядке, для чего коробка, установленная под платформой, предлагает три магнитных хвоста. На полном заряде сенсоров играть можно 6-7 часов, то есть игрока начнет тошнить от VR куда быстрее. Ну, либо разрядится хедсет, тут уж как повезёт и натренируешься. Коробка, установленная под платформой, подключается к сенсорам беспроводным образом и передаёт сигнал с них на компьютер. На компе устанавливается "KAT Gateway", позволяющий играть в PCVR игры. Поддерживается Steam и Oculus игры запускаемые на PC, с разной степенью поддержки. С точки зрения игры, движение на платформе транслируется в наклон джойстика пропорционально скорости движения, азимут наклона вычисляется за счет разницы между направлением взгляда (полученным с хедсета) и направлением тела (полученным с платформы). В итоге это работает во всех играх, поддерживающих locomotion с заданием относительно направления взгляда.

Влияние платформы на укачивание

Многие игроки VR знают, что игра ограничена зачастую не батарейкой в хедсете, а батарейкой в вестибулярном аппарате. Мои попытки поиграть в HL Alyx до покупки платформы закончились где-то за суммарно 3 часа игры. При этом редкая сессия была больше 20 минут, так как сидеть и двигаться джойстиком укачивало. Даже если включить затемнение, даже если использовать телепорт... Различные ухищрения типа двигаться одновременно шагая на месте для меня не работали.

Игра на платформе за счет бОльшей реалистичности растягивает до 1.5-2 часов сессию, но всё сильно зависит от игры, и от того, насколько "естественно" воспринимается движение в конкретной игре. Можно пробовать поиграться с настройками максимальной скорости, ускорения, подредактировать высоту от земли, и прочее, что может сделать лучше (а может и хуже).

На Quest 2 еще помогло перейти на 120Hz обновление, а с апгрейдом на Quest 3 в связи с поддержкой Wifi6 стало еще лучше. В текущем варианте, в комнате с платформой стоит PC для игры, комп подключен проводом к TP-Link RE705X расширителю, расширитель подключен к головному Zyxel в Mesh режиме. Несмотря на то, что подключен он беспроводом, VirtualDesktop показывает полный канал, 2404 MBps.

Для комфортной игры и снижения задержек можно вообще использовать отдельный роутер, без конфликтов и напрямую комп<=>хедсет, навроде D-Link VR Air Bridge.

Главное, к чему я веду: платформа это не панацея от укачивания, но значительно улучшает ситуацию.

Игры на платформе

Само движение хоть и рекламируется как "натуральное", всё же не очень натуральное. Самое близкое ощущение -- это как тащить за спиной телегу через воду. Удобнее всего получается когда наклоняешься вперед и толкаешь ногами позади себя. Тогда они хорошо и легко скользят и вестибулярный аппарат привыкает очень быстро. Для стрейфов можно просто одной ногой "подтащить" себя в сторону (нога ставится сбоку и слегка проводится по платформе, вес с другой ноги не переносится). Для особо активных моментов в игре можно использовать джойстик. В PvE играх в общем-то не требуется, в PvP совсем без не получается.

Движение на платформе получается более интенсивное чем просто ходьба, даже без тактильного жилета потеть начинаешь через 15 минут игры -- и это не смотря на то, что большинство игр не очень-то предназначены для игры на платформе, и фактического движения получается хорошо если 30% от времени игры. В HL Alyx, особенно когда начинаешь исследовать каждый закоулок и шевелить всем что плохо лежит, -- как раз где-то треть времени остается на движение ногами.

Чуть лучше в Talos Principle VR. В ней если не останавливаться особо на осмотр красот (что, впрочем, глупо, так как игра действительно достойна прогулки в ней), то активного движения выходит на 50%, особенно если помнить решение (к сожалению, Talos Principle без VR я прошел слишком недавно, еще 10 лет не прошло).

Еще отличные симуляторы прогулок это:

  • No Man's Sky (но игра имеет сложности с VR игрой из-за привязанности HUDа к фиксированному положению относительно мира, что ломает игру на платформе),

  • Skyrim (особенно рекомендуется поставить модпак FUS, но тут я не советник),

  • Несколько человек рекомендовали в discord для игры Borderlands 2 VR, но в агрессивном режиме, когда цель -- пробежать быстрее, при этом движения больше, пауз на битвы меньше, но это отдельный фан.

И, разумеется, игры типа Pavlov, Zero Caliber, Contractors -- прекрасный вариант для любителей шутеров в PvP и PvE. Вытягивать, впрочем, за счет тактики и скила придётся, так как на подвижности игроки на платформах немного проигрывают.

Список игр приведенный на сайте не полон, еще работают многие игры к которым есть VR Mod. К примеру, Firewatch (c VR модом) работает прекрасно и даёт отличное погружение -- с неплохим процентом прогулки.

Игры с платформой

Но играть во всё уже готовенькое -- скучно. Надо же чем-то развлечься, пока хедсет заряжается. Давайте в порядке развлечения поиграем с SDK для разработчиков.

SDK предоставляется в форме большого архива, внутри лежат готовые привязки к Unreal, Unity, ассеты и прочие вещи, интересные разработчикам игр. Я игры не разрабатываю, я в них играю, поэтому пойдём смотреть глубже, в KATVRUniversalSDK/Source/KATVRUniversalSDK, в котором лежат структуры от KATNativeSDK.dll.

Прочитав KATSDKWarpper.h мы узнаём, что нам доступно:

  • int DeviceCount() -- возвращает число подключенных устройств

  • KATDeviceDesc GetDevicesDesc(unsigned index) -- описание подключенного устройства за номером таким-то. Описание устройства включает в себя серийник, название, VID/PID подключенного USB хвоста и "тип" устройства.

  • KATTreadMillMemoryData GetWalkStatus(const char* sn) -- получение слепка состояния с платформы.

Всё прочее не так интересно. Самое интересное, разумеется, в GetWalkStatus:

struct DeviceData
{
    bool    btnPressed;
    bool    isBatteryCharging;
    float   batteryLevel;
    char    firmwareVersion;
};

struct TreadMillData
{
    char         deviceName[64];
    bool         connected;
    double       lastUpdateTimePoint;
    Quaternion   bodyRotationRaw;
    Vector3      moveSpeed;
};

struct KATTreadMillMemoryData
{
    TreadMillData treadMillData;
    DeviceData    deviceDatas[3];
    char          extraData[128];
};

Итак, нам доступны последние данные и момент их актуализации, состоящие, по сути, из состояния подключения, кватерниона направления тела и вектора вычисленной скорости. Что-то как-то мало, а где скорости ног по отдельности? Где направление взгляда? Gateway же рисует их:

Пристальнее посмотрим на них чуть-чуть позднее, для начала поиграемся с тем, что есть. Итак, что мы хотим? Мы хотим вытащить всё что нам доступно, без привязки к внешним библиотекам (ну, не без KATNativeSDK.dll, по крайней мере, пока, конечно же).

Создадим в студии консольное приложение, вытащим из заголовника определения, (не забываем #pragma pack(push,1)/ #pragma pack(pop), разумеется), оставляем только важные нам функции:

using deviceCountFunc = int(void);
using getDevicesDescFunc = KATDeviceDesc(unsigned);
using getWalkStatusFunc = KATTreadMillMemoryData(const char*);

const char* const sKatDeviceType(int deviceType)
{
    switch (deviceType) {
    case 0: return "ERR";
    case 1: return "Treadmil";
    case 2: return "Tracker";
    default: return "?";
    }
}

И накидаем тестовый примерчик:

int main()
{
    HMODULE hKatDll = LoadLibraryW(L"KATNativeSDK.dll");
    if (!hKatDll || hKatDll == INVALID_HANDLE_VALUE) {
        std::cout << "Whoops, World!\n";
        exit(1);
    }
    std::cout << "DLL loaded!\n";

    std::function<deviceCountFunc> deviceCount = reinterpret_cast<deviceCountFunc*>(GetProcAddress(hKatDll, "DeviceCount"));
    if (!deviceCount) {
        std::cout << "Whoops, <DeviceCount> Not Found\n";
        exit(1);
    }

    std::function<getDevicesDescFunc> getDevicesDesc = reinterpret_cast<getDevicesDescFunc*>(GetProcAddress(hKatDll, "GetDevicesDesc"));
    if (!getDevicesDesc) {
        std::cout << "Whoops, <GetDevicesDesc> Not Found\n";
        exit(1);
    }

    std::function<getWalkStatusFunc> getWalkStatus = reinterpret_cast<getWalkStatusFunc*>(GetProcAddress(hKatDll, "GetWalkStatus"));
    if (!getWalkStatus) {
        std::cout << "Whoops, <getWalkStatusFunc> Not Found\n";
        exit(1);
    }
    
    int count = deviceCount();
    std::cout << "Kat Devices found: " << count << "\n";
    for (int i = 0; i < count; ++i) {
        std::cout << "== Kat Device #" << i << "\n";
        KATDeviceDesc desc = getDevicesDesc(i);
        std::cout << "  Name: " << desc.device << "\n";
        std::cout << "  S/N : " << desc.serialNumber << "\n";
        std::cout << "  ID  : " << std::hex << desc.pid << ":" << desc.vid << "\n";
        std::cout << "  Type: " << desc.deviceType << "(" << sKatDeviceType(desc.deviceType) << ")\n";
    }
    
    KATTreadMillMemoryData data = getWalkStatus(nullptr);
    std::cout << "Device: " << data.treadMillData.deviceName << (data.treadMillData.connected ? " " : " not ") << "connected" << "\n";
    std::cout << "== Kat Walk Status:\n";
    for (int i = 0; i < 3; ++i)
    {
            std::cout << "Dev" << i << ": Battery: " << data.deviceDatas[0].batteryLevel << "; "
            << "Btn: " << data.deviceDatas[0].btnPressed << "; "
            << "firmware: " << (int)data.deviceDatas[0].firmwareVersion << "; "
            << "charging: " << data.deviceDatas[0].isBatteryCharging << "\n";
    }
    std::cout << "Rotation: ("
        << data.treadMillData.bodyRotationRaw.x << ", "
        << data.treadMillData.bodyRotationRaw.y << ", "
        << data.treadMillData.bodyRotationRaw.z << ", "
        << data.treadMillData.bodyRotationRaw.w << ")\n";
    std::cout << "Move speed: ("
        << data.treadMillData.moveSpeed.x << ", "
        << data.treadMillData.moveSpeed.y << ", "
        << data.treadMillData.moveSpeed.z << ")\n";
    return 0;
}

Что ж, мы получили сырые данные. Что мы можем с ними сделать?... Давайте получим угол поворота тела, аналогично тому, что рисует гейтвей. У нас есть кварт направления, а что есть требуемый азимут? Давайте просто посмотрим, как это делает сам гейтвей.

Скачиваем JetBrains dotPeek, грузим KAT Gateway.exe. Смотрим, есть ли что-то напоминающее углы -- нет. Окей, смотрим в Program:

      switch (ComUtility.KATDevice)
      {
        case ComUtility.KATDeviceType.loco:
          Application.Run((Form) new Home_Form(showMode, ComUtility.KATDeviceType.loco));
          break;
        case ComUtility.KATDeviceType.loco_s:
          Application.Run((Form) new Home_Form(showMode, ComUtility.KATDeviceType.loco_s));
          break;
        case ComUtility.KATDeviceType.walk_c:
          Application.Run((Form) new Home_Form(showMode, ComUtility.KATDeviceType.walk_c));
          break;
        case ComUtility.KATDeviceType.walk_c2:
          Application.Run((Form) new Home_Form(showMode, ComUtility.KATDeviceType.walk_c2));
          break;
        case ComUtility.KATDeviceType.mini:
          Application.Run((Form) new Home_Form(showMode, ComUtility.KATDeviceType.mini));
          break;
        case ComUtility.KATDeviceType.walk_c2_core:
          Application.Run((Form) new Home_Form(showMode, ComUtility.KATDeviceType.walk_c2_core));
          break;
      }

идём в Home_Form, читаем конструктор:

    public Home_Form(bool show, ComUtility.KATDeviceType kATDeviceType)
    {
// ... bla bla bla ...
        this.Open_Home_Form_Walk_C_Center_Left(kATDeviceType); // ahha?
// ... bla bla bla ...
    }

    private void Open_Home_Form_Walk_C_Center_Left(ComUtility.KATDeviceType kATDeviceType)
    {
      switch (kATDeviceType)
      {
        case ComUtility.KATDeviceType.loco:
          this.OpenForm(new PluginHelper().LoadPlugInForm((Form) this, "KAT_Loco_Dx.Home_Form_Loco_Main,KAT_Loco_Dx.dll", ParameterType.Home, (Dictionary<string, object>) null, true), this.panel_Center_Left);
          break;
        case ComUtility.KATDeviceType.loco_s:
          this.OpenForm(new PluginHelper().LoadPlugInForm((Form) this, "KAT_LocoS_Dx.Home_Form_Loco_S_Main,KAT_LocoS_Dx.dll", ParameterType.Home, (Dictionary<string, object>) null, true), this.panel_Center_Left);
          break;
        case ComUtility.KATDeviceType.walk_c:
          this.OpenForm(new PluginHelper().LoadPlugInForm((Form) this, "KAT_WalkC_Dx.Home_Form_Walk_C_Main,KAT_WalkC_Dx.dll", ParameterType.Home, (Dictionary<string, object>) null, true), this.panel_Center_Left);
          break;
        case ComUtility.KATDeviceType.walk_c2:
          this.OpenForm(new PluginHelper().LoadPlugInForm((Form) this, "KAT_WalkC2_Dx.Home_Form_Walk_C2_Main,KAT_WalkC2_Dx.dll", ParameterType.Home, (Dictionary<string, object>) null, true), this.panel_Center_Left);
          break;
        case ComUtility.KATDeviceType.mini:
          this.OpenForm(new PluginHelper().LoadPlugInForm((Form) this, "KAT_Mini_Dx.Home_Form_Mini_Main,KAT_Mini_Dx.dll", ParameterType.Home, (Dictionary<string, object>) null, true), this.panel_Center_Left);
          break;
        case ComUtility.KATDeviceType.walk_c2_core:
          this.OpenForm(new PluginHelper().LoadPlugInForm((Form) this, "KAT_WalkC2_Dx.Home_Form_Walk_C2_Main,KAT_WalkC2_Dx.dll", ParameterType.Home, (Dictionary<string, object>) null, true), this.panel_Center_Left);
          break;
      }
    }

Ага, хорошо, грузим KAT_WalkC2_Dx.dll, идём в Home_Form_Walk_C2_Main:

// ...
    private Label label_HMD;
    private Label label_HMD_Title;
    private Label label_Waist_Title;
    private Label label_Left_Foot_Title;
    private Label label_Right_Foot_Title;
    private ToolTip toolTip;
    private Panel button_haptic;
    private Panel panel_Tracker;
    private System.Windows.Forms.Timer timerGDIWalkPaint;
    private Panel panel_LED;
    private Panel panel1;
    private Label label_Waist;
// ...

Так, да, это та форма что нам нужна. Угол, значит, назвали поясом, хорошо, жмём на label_Waist правой кнопкой, "Find Usages" читаем:

// ...
        if (KATSDKInterfaceHelper.Receiver_status == 0 || KATSDKInterfaceHelper.Compass_status == 0)
          this.label_Waist.Text = "0°";
        else
          this.label_Waist.Text = ((double) MotionDriver.Angle < 360.0 ? ((double) MotionDriver.Angle >= 0.0 ? Convert.ToInt32(MotionDriver.Angle) : Convert.ToInt32(MotionDriver.Angle + 360f)) : Convert.ToInt32(MotionDriver.Angle - 360f)).ToString() + "°";

хорошо, а что про `MotionDriver.Angle`?

    KATSDKInterfaceHelper.Quat q = new KATSDKInterfaceHelper.Quat();
    q.w = Home_Form_Walk_C2_Main.objTreadMillData.bodyRotationRaw.w;
    q.x = Home_Form_Walk_C2_Main.objTreadMillData.bodyRotationRaw.x;
    q.y = Home_Form_Walk_C2_Main.objTreadMillData.bodyRotationRaw.y;
    q.z = Home_Form_Walk_C2_Main.objTreadMillData.bodyRotationRaw.z;
    double num2 = (double) KATSDKInterfaceHelper.EulerAngles(q).y / Math.PI * 180.0;

Вот так, никаких сюрпризов. Поиском по файлам выясняем, что KATSDKInterfaceHelper лежит в IBizLibrary, так что грузим её тоже. Смотрим на EulerAngles:

    public static KATSDKInterfaceHelper.Vector3 EulerAngles(KATSDKInterfaceHelper.Quat q)
    {
      KATSDKInterfaceHelper.Vector3 vector3 = new KATSDKInterfaceHelper.Vector3();
      float num1 = q.w * q.w;
      double num2 = (double) q.x * (double) q.x;
      float num3 = q.y * q.y;
      float num4 = q.z * q.z;
      double num5 = (double) num3;
      float num6 = (float) (num2 + num5) + num4 + num1;
      float num7 = (float) ((double) q.x * (double) q.w - (double) q.y * (double) q.z);
      if ((double) num7 > 0.49950000643730164 * (double) num6)
      {
        vector3.y = 2f * (float) Math.Atan2((double) q.y, (double) q.x);
        vector3.x = 1.57079637f;
        vector3.z = 0.0f;
        return vector3;
      }
      if ((double) num7 < -0.49950000643730164 * (double) num6)
      {
        vector3.y = -2f * (float) Math.Atan2((double) q.y, (double) q.x);
        vector3.x = -1.57079637f;
        vector3.z = 0.0f;
        return vector3;
      }
      KATSDKInterfaceHelper.Quat quat = new KATSDKInterfaceHelper.Quat(q.y, q.w, q.z, q.x);
      vector3.y = (float) Math.Atan2(2.0 * (double) quat.x * (double) quat.w + 2.0 * (double) quat.y * (double) quat.z, 1.0 - 2.0 * ((double) quat.z * (double) quat.z + (double) quat.w * (double) quat.w));
      vector3.x = (float) Math.Asin(2.0 * ((double) quat.x * (double) quat.z - (double) quat.w * (double) quat.y));
      vector3.z = (float) Math.Atan2(2.0 * (double) quat.x * (double) quat.y + 2.0 * (double) quat.z * (double) quat.w, 1.0 - 2.0 * ((double) quat.y * (double) quat.y + (double) quat.z * (double) quat.z));
      return vector3;
    }

Выглядит интересно, но нам не надо. нам надо только yaw (y), плюс эксперименты показывают, что данные уже нормализованы. Гуглинг по библиотекам кватернионов выдаёт еще вариант попроще, которые после объединения превращаются в нечто такое:

struct Quaternion
{
    //...

    static Quaternion StraightUp() {
        const float deg90 = (float)M_PI_4;
        const float s = sin(deg90);
        const float c = sin(deg90);
        return { s * 0.0f, s * -1.0f, s * 0.0f, c };
    }

    float getRawAngle()
    {
        return 2.f * acos(this->w);
    }

    float getAngle()
    {
        Quaternion angle = Quaternion::StraightUp() * (*this);
        return angle.getRawAngle();
    }

что, после раскрытия всех скобок, сворачивается в простой и понятный код, который работает только с хорошо нормализованными данными, но, как я уже сказал выше, у нас они именно такие:

    float getAngle()
    {
        return (float)(2.f * acos(M_SQRT1_2 * (w + y)));
    }

Добавляем в код распечатку угла, крутим платформу -- да, работает... Но что-то не то. Может, интереснее будет переписать на Python?

Что-то вот такое
...
import ctypes
...
##
# Load

katsdk = ctypes.windll.LoadLibrary(os.getcwd()+os.sep+"KATNativeSDK.dll")  # place the dll near the script

devco = katsdk.DeviceCount()
print(f"SDK reports {devco} devies connected")

##
# GetDevicesDesc

def katDevType(typeId):
    types = ["ERR", "Treadmill", "Tracker"]
    if typeId < 0 or typeId > len(types):
        return "?"
    return types[typeId]

class KATDeviceDesc(ctypes.Structure):
    _pack_ = 1
    _fields_ = [
        ("device", ctypes.c_char * 64),
        ("serialNumber", ctypes.c_char * 13),
        ("pid", ctypes.c_int32),
        ("vid", ctypes.c_int32),
        ("deviceType", ctypes.c_int32)
    ]

katsdk.GetDevicesDesc.restype = KATDeviceDesc

for k in range(0, devco):
    print(f"\nSDK request for device # {k}:")
    desc = katsdk.GetDevicesDesc(k)
    print(f"  Name: {desc.device}")
    print(f"  S/N : {desc.serialNumber}")
    print(f"  ID  : {hex(desc.pid)}:{hex(desc.vid)}")
    print(f"  Type: {katDevType(desc.deviceType)}")

##
# GetWalkStatus
...

class KATTreadMillMemoryData(ctypes.Structure):
    _pack_ = 1
    _fields_ = [
        ("treadMillData", TreadMillData),
        ("deviceDatas", DeviceData * 3),
        ("extraData", ctypes.c_byte * 128)
    ]

katsdk.GetWalkStatus.restype = KATTreadMillMemoryData
katsdk.GetWalkStatus.argtypes = [ctypes.c_char_p]

walk = katsdk.GetWalkStatus(None)
print("Walk data:")
print(f"  Device: {walk.treadMillData.deviceName}, {walk.treadMillData.connected and "" or " not "}connected")
print(f"  Sensors:")
...

М... Нет. Слишком просто, тупое копирование. Конечно, можно автоматизировать, но не то.

Давайте играть в игры, No Man's Sky с платформой

Нет, надо всё же играть в игры. Давайте возьмём возьмём какую-либо игру, и сделаем немножко лучше!

Мой выбор пал на No Man's Sky, где мои первые попытки понять игру уперлись в непонимание "а что же делать вообще". Проблема в том, что движение в игре работает с платформой без проблем, но вот все подсказки и задачи висят в воздухе и не вращаются и за головой, ни за телом! То есть чтобы понять что происходит, надо либо постоянно сбрасывать положение, либо поворачиваться чтоб посмотреть "а что же я делаю сейчас". Непорядок.

Итак, у нас есть игра, в которой надо чуть-чуть поправить консерваторию. Скачиваем IDA Freeware, грузим nms.exe в неё. Видим кашу, соображаем, что игра из Steam -- так что скачиваем steamless, распаковываем, и грузим NMS.exe.unpacked.exe.

И понимаем, что надо откуда-то начинать. Откуда? Для начала поищем по чему-нибудь разумному, например, "HMD":

.rdata:00000001428E7310 48 4D 44 5F 52 65+aHmdRecenter    db 'HMD_Recenter',0     ; DATA XREF: .data:0000000142D56358↓o
.rdata:00000001428E731D 00 00 00                          align 20h
.rdata:00000001428E7320 48 4D 44 5F 52 65+aHmdRecenter2   db 'HMD_Recenter2',0    ; DATA XREF: .data:0000000142D56368↓o
.rdata:00000001428E732E 00 00                             align 10h
.rdata:00000001428E7330 48 4D 44 5F 46 45+aHmdFeopen      db 'HMD_FEOpen',0       ; DATA XREF: .data:0000000142D56378↓o

Ага, неплохо, но ссылок на них больше нет, еще?

.rdata:00000001428EB278 55 73 65 50 6C 61+aUseplayercamer db 'UsePlayerCameraInHmd',0
.rdata:00000001428EB278 79 65 72 43 61 6D+                                        ; DATA XREF: sub_14151D400+31E↑o
.rdata:00000001428EB278 65 72 61 49 6E 48+                                        ; sub_14151F190+5E9↑o ...
.rdata:00000001428EB28D 00 00 00                          align 10h
.rdata:00000001428EB290 41 6C 69 67 6E 55+aAlignuitocamer db 'AlignUIToCameraInHmd',0
.rdata:00000001428EB290 49 54 6F 43 61 6D+                                        ; DATA XREF: sub_14151D400+334↑o
.rdata:00000001428EB290 65 72 61 49 6E 48+                                        ; sub_14151F190+63A↑o ...
.rdata:00000001428EB2A5 00 00 00                          align 8
.rdata:00000001428EB2A8 55 73 65 53 65 6E+aUsesensiblecam db 'UseSensibleCameraFocusNodeIsNowOffsetNode',0
.rdata:00000001428EB2A8 73 69 62 6C 65 43+                                        ; DATA XREF: sub_14151D400+34A↑o
.rdata:00000001428EB2A8 61 6D 65 72 61 46+                                        ; sub_14151F190+68B↑o ...
.rdata:00000001428EB2D2 00 00 00 00 00 00                 align 8

Уже лучше! А что на них смотрит?

// ...
  sub_1414834D0(a1 + 128, a2, "FocusInterpTime");
  sub_1414834D0(a1 + 132, a2, "BlendInTime");
  sub_1414834D0(a1 + 136, a2, "BlendInOffset");
  sub_1414A7FF0(a1 + 144, a2, "Anim");
  sub_1414834D0(a1 + 160, a2, "HeightOffset");
  sub_14148DDD0(a1 + 164, a2, "UsePlayerCameraInHmd");
  sub_14148DDD0(a1 + 165, a2, "AlignUIToCameraInHmd");
  sub_14148DDD0(a1 + 166, a2, "UseSensibleCameraFocusNodeIsNowOffsetNode");
  return sub_14148DDD0(a1 + 167, a2, "LookForFocusInMasterModel");
// ...

Вообще хорошо, у нас то-ли загрузка, то-ли сохранение настроек, неважно можно начинать копать!

Откручиваем назад, попадаем на интересный массивчик:

.rdata:00000001428D2E38 10 E4 03 40 01 00+        dq offset sub_14003E410
.rdata:00000001428D2E40 D0 DA 03 40 01 00+        dq offset sub_14003DAD0
.rdata:00000001428D2E48 40 E4 03 40 01 00+        dq offset sub_14003E440
.rdata:00000001428D2E50 50 DB 03 40 01 00+        dq offset sub_14003DB50
.rdata:00000001428D2E58 30 E5 03 40 01 00+        dq offset sub_14003E530
.rdata:00000001428D2E60 D0 DD 03 40 01 00+        dq offset sub_14003DDD0
.rdata:00000001428D2E68 60 E5 03 40 01 00+        dq offset sub_14003E560
.rdata:00000001428D2E70 50 DE 03 40 01 00+        dq offset sub_14003DE50
.rdata:00000001428D2E78 90 E5 03 40 01 00+        dq offset sub_14003E590

в котором много вкусного, с функциями типа

__int64 sub_14003E410()
{
  return sub_142512100("cTkGlobals", sub_141535BB0, sub_141533E70, sub_1415355A0);
}

Что позволяет разметить кучу кода логикой, если нам захочется. Но хочется другого, более простого.

Что мы знаем? Что если нажать на джойстик влево-вправо, можно вращать экран. При этом всплывашка двигается вместе с нами, и, если включить, тело вращается тоже. А что если привязать поворот тела к повороту джойстика? В настройках есть выбор вращения -- Snap и Smooth.

Продолжая поиски по интересным вещам, относящимся к "Snap", находим функцию, выглядит как то, что нам нужно, (еще и как выяснилось позднее -- отслеживает количество вызовов операции):

        v39 = fastCos(v28.m128_f32[0]);
        v40 = fastSin(v28.m128_f32[0]);
        v41 = _mm_xor_ps(v40, (__m128)0x80000000);
        v42 = _mm_unpacklo_ps(_mm_unpacklo_ps(v41, v39), (__m128)xmmword_142B2B830);
        v43 = _mm_unpacklo_ps(_mm_unpacklo_ps(v39, v40), (__m128)xmmword_142B2B830);
        sub_140178B20(&v647);
        v647 = _mm_add_ps(
                  _mm_add_ps(
                    _mm_mul_ps(*(__m128 *)(a1 + 21264), _mm_shuffle_ps(v43, v43, 0)),
                    _mm_mul_ps(*(__m128 *)(a1 + 21280), (__m128)0i64)),
                  _mm_mul_ps(*(__m128 *)(a1 + 21296), _mm_shuffle_ps(v43, v43, 170)));
        v648 = _mm_add_ps(
                  _mm_add_ps(
                    _mm_mul_ps(*(__m128 *)(a1 + 21264), (__m128)0i64),
                    _mm_mul_ps(*(__m128 *)(a1 + 21280), (__m128)xmmword_142B2CE10)),
                  _mm_mul_ps(*(__m128 *)(a1 + 21296), (__m128)0i64));
        v649 = _mm_add_ps(
                  _mm_add_ps(
                    _mm_mul_ps(*(__m128 *)(a1 + 21264), _mm_shuffle_ps(v41, v41, 0)),
                    _mm_mul_ps(*(__m128 *)(a1 + 21280), (__m128)0i64)),
                  _mm_mul_ps(*(__m128 *)(a1 + 21296), _mm_shuffle_ps(v42, v42, 170)));
        v650 = _mm_add_ps(
                  _mm_add_ps(
                    _mm_add_ps(
                      _mm_mul_ps(*(__m128 *)(a1 + 21264), (__m128)0i64),
                      _mm_mul_ps(*(__m128 *)(a1 + 21280), (__m128)0i64)),
                    _mm_mul_ps(*(__m128 *)(a1 + 21296), (__m128)0i64)),
                  *(__m128 *)(a1 + 21312));
        *(__m128 *)(a1 + 21264) = v647;
        *(__m128 *)(a1 + 21280) = v648;
        *(__m128 *)(a1 + 21296) = v649;
        *(__m128 *)(a1 + 21312) = v650;
        sub_14133C550((char *)qword_144B28508 + 10437728, "SNAPTURN");
        v44 = 0i64;
        *((_BYTE *)qword_144B28508 + 9046820) = 1;
        v45 = (__int64 *)qword_144B28508;
        statHash = 0i64;
        do
        {
          v46 = aVrSnapturns[v44];
          *((_BYTE *)&statHash + v44) = v46;
          if ( (unsigned __int8)(v46 - 97) <= 0x19u )
            *((_BYTE *)&statHash + v44) = v46 - 32;
          ++v44;
        }
        while ( v44 < 0xD );
        *(_WORD *)((char *)&statHash + 13) = 0;
        HIBYTE(statHash) = 0;
        gameStats(v45 + 235493, &statHash, 1, 0, 0i64);
      }
    }

sin+cos подряд, потом разные матричные операции -- знакомая картина модификации векторов и матриц. Если отмотать еще выше, и внимательно почитать (и почему я раньше не читал декомпиляцию, а полагался чисто на ассемблер?):

      v13 = dword_1432AC404;
      if ( sub_1404B12C0(*((_QWORD *)qword_144B28508 + 1304414)) )
        v13 = dword_1432AC408;
      *(_BYTE *)(a1 + 12972) = 0;
      v14 = *(__int64 **)(a1 + 424);
      turnRadInput = *(float *)&v13 * 0.017453292;
      v16 = *v14;
      turnRad = turnRadInput;
      if ( (*(unsigned __int8 (__fastcall **)(__int64 *, __int64, __int64))(v16 + 8))(v14, 155i64, 1i64) )
        *(_BYTE *)(a1 + 12972) = 1;
      if ( (*(unsigned __int8 (__fastcall **)(_QWORD, __int64, __int64))(**(_QWORD **)(a1 + 424) + 8i64))(
              *(_QWORD *)(a1 + 424),
              156i64,
              1i64) )
      {
        turnRadInput = -turnRadInput;
        *(_BYTE *)(a1 + 12972) = 1;
        turnRad = turnRadInput;
      }
      if ( *(_BYTE *)(a1 + 12972) )
      {
        if ( dword_144626580 > *(_DWORD *)(*(_QWORD *)NtCurrentTeb()->ThreadLocalStoragePointer + 152i64) )
        {
          Init_thread_header(&dword_144626580);
          if ( dword_144626580 == -1 )
          {
            sub_1401E33D0(stru_144623A40);
            Init_thread_footer(&dword_144626580);
          }
        }
        v17 = (__m128 *)GetPosition(a1, v675);

Мы видим, что в зависимости от вызова какого-то метода с параметром 155 или 156 (снап-лево, снап-право). Еще выше аналогичный блок кода (ух, жесткий инлайнинг!), где параметр угла вычитывается напрямую, а не константа как тут.

Хорошо, у нас есть точка обработки пользовательского ввода и тут же реакции на неё, идеальное место для вторжения. У нас есть последовательность проверок:

  • Нажато влево? => ставим флаг

  • Нажато вправо? => ставим флаг, меняем знак угла поворота

  • Нет флага? Не поворот, уходим.

Правим логику:

  • Нет флага? Вызовем наш перехватчик.

  • Результат нулевой? Не поворот, уходим.

Там надо добавить перехватчик, который будет:

  • Обновлять угол поворота с платформы,

  • Если угол изменился -- вернуть дельту.

что-то вроде такого:

  static float old = 0;
  KATTreadMillMemoryData newdata = getWalkStatus(nullptr);
  float angle = 2.0 * acos(newdata.bodyRotationRaw.w);
  float diff = angle - old;
  if (abs(diff) > 0.001) {
    xmm7 = diff;
    old = angle;
    jmp $do_turn$;
  }
  jmp handle_turn_no_turn;

При этом патчить надо память процесса, в который еще и загружен DRM от Steam...

На помощь приходит Reloaded-II! Прекрасный инструмент, берущий на себя рутину по патчингу, обработке стима и прочих вещей, оставляя для нас только интересную часть.

Код, правда, на Си Шарп, но что ж теперь, переносим вызовы API на C#. Особой сложности тут нет, опять же механический перенос, разве что надо обернуть загруженные функции в обертки, предоставленнные самим Reloaded-II для совместимости.

Первая сложность всплывает с возвратом угла, который надо складывать в конкретный регистр. Ну, решение простое -- функция будет возвращать bool, а требуемый угол поворота будем складывать в глобальную переменную, доступную из программы, и пишем код самой функции:

    private static unsafe float* _angleDiff = (float*)Marshal.AllocHGlobal(sizeof(float));

    const float M_2PI = (float)(2.0 * Math.PI);

    private static float _lastAngle = 0.0f;
    private static byte _GetTurnAngleDiff()
    {
        KATTreadMillMemoryData data;
        GetWalkStatusWrapper!(out data, 0);
        float angle = data.bodyRotationRaw.GetAngle();
        if (Math.Abs(angle - _lastAngle) > 0.0001)
        {
            float diff = _lastAngle - angle;
            if (diff > Math.PI) diff -= M_2PI;
            else if (diff < -Math.PI) diff += M_2PI;
            unsafe { *_angleDiff = diff; }
            _lastAngle += diff;
            if (_lastAngle < 0) _lastAngle += M_2PI;
            else if (_lastAngle > M_2PI) _lastAngle -= M_2PI;
            return 1;
        }
        return 0;
    }

Так как мы работаем с флоатами, да еще передаём в игру дельты, не хотелось бы накапливать ошибку. Чтобы этого не происходило, мы не просто сохраняем последний угол который мы знаем, а интегрируем те же дельты, что передаём в игру. Таким образом, если где-то будет копиться расхождение -- в какой-то прекрасный момент оно попадёт в дельту и обработается.

Теперь наступает интересная часть игры: инъекция кода. Reloaded-II предоставляет набор инструментов для инъекций и отката их, эффективного поиска по памяти, объединения инсталляторов разных плагинов параллельно и так далее. Всё хорошо, но развивается инструмент чуть быстрее, чем его документация (как всегда), а мне не терпится посмотреть на результат, поэтому берём простейший вариант использования -- синхронный поиск и инъекция; простеший вариант инъекции -- необратимый. Выкусываем из разных примеров и семплов и подгоняем под себя.

Нам потребуется: сканнер памяти, чтобы найти требуемый участок кода куда; кусочек свободной памяти чтоб было куда встроить логику (вызов, сравнения, загрузка переменной, переход и тд), и сигнатура куска куда мы будем страиваться.

Можно сделать "правильно", повторяя ручную работу (найти "VR_SNAPTURNS", найти ссылку на неё, найти try{} блок перед ней, найти проверку на флаг, найти смену знака => проверить что вся цепочка не изменилась). Но, повторюсь, хочется посмотреть как оно работает. Так что грузим в IDA последний вариант бинарника (да-да, два апдейта вышло пока я изучал её), вручную пробегаемся, получаем актуальную сигнатуру:

    // Signature:
    //   $do_turn$:
    // (+ 0)  C6 87 [1C 33 00 00] 01       mov     byte ptr[rdi + 331Ch], 1; we turn
    // (+ 7)  F3 0F 11 [BD 58 13 00 00]    movss[rbp + 1330h + arg_18], xmm7; turn radians
    //   $check_addr$:
    // (+15)  38 9F [1C 33 00 00]          cmp[rdi + 331Ch], bl
    // (+21)  0F 84 [9B 23 00 00]          jz no_turn_needed [ no turn pressed handling ]
    //   $turn_handling$:
    // (+27)
    const string Signature = "C6 87 1C 33 00 00 01 F3 0F 11 ?? ?? ?? 00 00 38 9F 1C 33 00 00 0F 84 ?? ?? 00 00";

и ищем её:

    var thisProcess = Process.GetCurrentProcess();
    byte* baseAddress = (byte*)thisProcess!.MainModule!.BaseAddress;
    int exeSize = thisProcess!.MainModule!.ModuleMemorySize;
    _modLoader.GetController<IScannerFactory>().TryGetTarget(out var scannerFactory);
    var scanner = scannerFactory!.CreateScanner(baseAddress, exeSize);
    var result = scanner.FindPattern(Signature);
    if (!result.Found)
    {
        _logger.WriteLine("Can't find signature");
        throw new Exception("Signature for getting LookHook not found.");
    }

Теперь немного о самой инъекции. У нас есть внешняя функция, которая возвращает bool, и есть внешняя переменная float. Нам надо сделать следующее:

    call _GetTurnAngleDiff
    cmp al, 0
    jz no_turn_needed
    mov xmm7, [_angleDiff]
    jmp do_turn_address

Но при этом надо помнить, что переходы и вызовы -- относительные, а нам нужно врезать абсолютные адреса, и код должен быть удобен для инъекции, то есть максимально позиционно-независимый. Так что подкрутив его, получим такой код для инъекции:

    call _GetTurnAngleDiff
    cmp al, 0
    mov rax, qword ${no_turn_needed_address}
    mov rcx, qword ${do_turn_address}
    cmovne rax, rcx
    mov rcx, qword ${_angleDiffAddr}
    movss xmm7, [rcx]
    jmp rax

В таком формате, нам нужны только -- код для вызова (его нам даст сам Reloaded-II), абсолютные адреса переменной и переходов, которые мы можем вычислить, благо мы только что нашли интересующий нас код. Берём адрес найденной сигнатуры, достаём из неё относительные смещения, складываем всё вместе.

    var do_turn_address = baseAddress + result.Offset;
    var turn_handling_address = baseAddress + result.Offset + 27;
    var no_turn_needed_address = *(int*)(do_turn_address + 23) + do_turn_address + 27;

Еще один плюс Reloaded-II -- не надо заниматься ручной трансляцией и поддержанием в актуальности еще и кода инъекций. Он сам умеет транслировать, и уже готовы макросы для встраивания вызовов (которые требуют не прямых вызовов функций, а указателей на врапперы -- см. документацию и полный код). Так что всё что нам надо, это завернуть код в массивчик:

    string[] turnAdapterHook =
    {
        "use64",
          // Get the rotation delta
        $"{_hooks!.Utilities.GetAbsoluteCallMnemonics<NoArgsRetByte>(_GetTurnAngleDiff, out _GetTurnAngleDiffReverse)}",
        // Check is no rotation needed
        "cmp al, 0",
        // If no rotation needed, load skip address
        $"mov rax, qword {(nuint)no_turn_needed_address}",
        // Otherwise load saving address
        $"mov rcx, qword {(nuint)do_turn_address}",
        $"cmovne rax, rcx",
        // Load the turn diff into xmm7
        $"mov rcx, qword {(nuint)(_angleDiff)}",
        "movss xmm7, [rcx]",
        // return from hook to either do_turn or no_turn_needed
        "jmp rax"
    };

Reloaded-II сам находит место куда положить хук, но чтобы его активировать, нужно место, куда вписать переход. Готовые примитивы заточены на врезание в начало функции (умеет переносить первые инструкции в новое место, например), но нам проще: найдём рядышком кусочек пустого места неподалёку, что мы сможем поменять адрес условного перехода на него.

    using var scanner2 = scannerFactory.CreateScanner((byte*)do_turn_address, exeSize-result.Offset);
    result = scanner2.FindPattern("CC CC CC CC CC CC CC CC");
    if (!result.Found)
    {
        throw new Exception("Can't find a gap in the code.");
    }
    if (result.Offset > 0x7FFFF000)
    {
        throw new Exception("The gap is too far away.");
    }
    var hook_jmp_address = do_turn_address + result.Offset;

После чего создаём хук:

    rotationHook = _hooks!.CreateAsmHook(
                       turnAdapterHook,
                       (long)hook_jmp_address, 
                       AsmHookBehaviour.DoNotExecuteOriginal
                   ).Activate();

и меняем условный переход:

    // Activate jmp to hook by chaning "jz no_turn_needed" into "jz hook_jmp_address"
    Memory.Instance.SafeWrite(
        (nuint)(turn_handling_address - 4),
        BitConverter.GetBytes((int)(hook_jmp_address - turn_handling_address)));

(Полный код плагина я выложил на github)

Самое удивительное, что код даже работает! Я тестировал его запуская игру за компом, дёргая за верёвочку чтобы покрутить платформу, и всё было хорошо, пока я не догадался встать на платформу и поиграть. Ну почему я не подумал об этом раньше?

...

...

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

Вот так, можно сыграть в дизассемблер и проиграть -- совсем не так, как ожидалось. %)

Что ж, придётся как-нибудь собраться и сделать второй заход на изучение игры, в поисках более подходящих мест для инъеций.

В следующих выпусках

В следующей статье мы рассмотрим в какие еще игры можно играть на платформе с помощью современного инструментария:

  • Посмотрим как устроен SDK внутри,

  • узнаем, что скрывает массивчик extraData,

  • получим данные с платформы напрямую,

  • создадим свой приемник прямо на адроиде и протестируем на хедсете,

  • и, разумеется, опустимся еще на уровень ниже!

Ссылки

  • Часть 1: "Играем с платформой" на [Habr], [Medium] и [LinkedIn].

  • Часть 2: "Начинаем погружение" на [Habr], [Medium] и [LinkedIn].

  • Часть 3: "Отрезаем провод" на [Habr], [Medium] и [LinkedIn].

  • Часть 4: "Играемся с прошивкой" на [Habr], [Medium] и [LinkedIn].

  • Часть 5: "Оверклокинг и багфиксинг" на [Habr], [Medium] и [LinkedIn].

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 13: ↑13 и ↓0+13
Комментарии10

Публикации

Истории

Ближайшие события

Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн
Антиконференция X5 Future Night
Дата30 мая
Время11:00 – 23:00
Место
Онлайн
Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург