Чит на креативний режим у Vintage Story через реверс-інжиніринг

Версія гри

Протестовано на Vintage Story v1.21.6 (Stable), .NET 8.0. Мод використовує бібліотеку Harmony, що йде в комплекті з грою.

Попередження

Це технічна стаття для вивчення будови гри. Перевіряйте описані прийоми лише в одиночній грі, на власному сервері або з дозволу власника сервера. Дотримуйтеся правил сервера.

Що ми робимо

Клієнтський мод для Vintage Story. Натискаємо Ctrl+B — клієнт перемикається у стан Creative: змінюються інтерфейс, керування рухом і поведінка ламання блоків. Натискаємо знову — назад у локальний Survival. Серверних прав Creative це не дає: для сервера гравець може залишатися у Survival.

Тут використовується клієнтський мод, а не зміну бінарних файлів. Нижче — фрагменти, а не повний відтворюваний проєкт: файл проєкту, посилання на залежності й навколишній код наведено не повністю.

Ось як це виглядає на реальному сервері:

TL;DR

Декомпілюємо VintagestoryLib.dll і розбираємо три Harmony-патчі: локальний стан Creative, вхідні оновлення режиму та вихідні запити, переписані у Survival. Локальний стан не дорівнює серверним правам, а видимий на клієнті ефект не доводить збережену зміну на сервері.


Розбираємо систему ігрових режимів

Клієнт-серверна архітектура

Vintage Story використовує модель, у якій сервер визначає права для ігрових режимів. Ось звичайний потік при спробі змінити режим:

Client                                     Server
  |                                          |
  |  Packet_Client (Id=20)                   |
  |  RequestModeChange {                     |
  |    GameMode=2 (Creative)                 |
  |    FreeMove=1, NoClip=1                  |
  |  }                                       |
  | ---------------------------------------> |
  |                                          |  перевіряє привілеї:
  |                                          |  PlayerHasPrivilege("gamemode")
  |                                          |  PlayerHasPrivilege("freemove")
  |                                          |
  |  Packet_Server (Id=46)                   |
  |  ModeChange {                            |
  |    GameMode=1 (Survival)  <- відхилено   |
  |    FreeMove=0, NoClip=0                  |
  |  }                                       |
  | <--------------------------------------- |
  |                                          |
  |  "Not allowed to change game mode"       |

Сервер перевіряє привілеї, відхиляє запит та відправляє назад початковий режим. Так працює звичайна зміна режиму.

Але ось ключове спостереження: ігровий режим керує й клієнтською поведінкою — інтерфейсом інвентаря, ламанням блоків, керуванням польотом і колізіями. Ці локальні перевірки читають поле режиму клієнта. Саме по собі це нічого не говорить про те, які дії сервер приймає та зберігає.

Декомпіляція ключового класу

Декомпіляція VintagestoryLib.dll за допомогою ILSpy розкриває клас, який зберігає локальний стан режиму — ClientWorldPlayerData:

public class ClientWorldPlayerData : IWorldPlayerData
{
    private EnumGameMode gameMode;
    private bool freeMove;
    private bool noClip;

    public EnumGameMode CurrentGameMode
    {
        get => gameMode;
        set => gameMode = value;  // <- звичайний сеттер, без побічних ефектів
    }

    public bool FreeMove
    {
        get => freeMove;
        set => freeMove = value;  // <- те саме, пакет не відправляється
    }

    public bool NoClip
    {
        get => noClip;
        set => noClip = value;    // <- те саме
    }

    // ЦЕЙ метод відправляє пакет на сервер:
    public void RequestMode(ClientMain game, float moveSpeed, float pickRange,
        EnumGameMode gameMode, bool freeMove, bool noClip,
        EnumFreeMovAxisLock freeMovePlaneLock, bool renderMetaBlocks)
    {
        Packet_Client packetClient = new Packet_Client
        {
            Id = 20,
            RequestModeChange = new Packet_PlayerMode
            {
                PlayerUID = PlayerUID,
                GameMode = (int)gameMode,
                FreeMove = freeMove ? 1 : 0,
                NoClip = noClip ? 1 : 0,
                // ...
            }
        };
        game.SendPacketClient(packetClient);
    }

    // Викликається коли сервер надсилає дані про режим:
    public void UpdateFromPacket(ClientMain game, Packet_PlayerMode mode)
    {
        gameMode = (EnumGameMode)mode.GameMode;
        freeMove = mode.FreeMove > 0;
        noClip = mode.NoClip > 0;
        // ...
    }

    // Інше перевантаження — теж перезаписує gameMode:
    public void UpdateFromPacket(ClientMain game, Packet_PlayerData packet)
    {
        gameMode = (EnumGameMode)packet.GameMode;
        freeMove = packet.FreeMove > 0;
        noClip = packet.NoClip > 0;
        // ...
    }
}
Як працюють сетери

Сетери властивостей (CurrentGameMode, FreeMove, NoClip) — це звичайні присвоєння полів. Встановлення CurrentGameMode = Creative НЕ викликає відправку пакета серверу. Це робить лише явний виклик методу RequestMode(). Сетер і мережевий виклик повністю розділені.

Це означає, що можна змінити CurrentGameMode локально без надсилання запиту режиму із сетера. Але сервер усе ще може спостерігати дії, які з цього випливають.

Три шляхи обміну даними

З декомпільованого коду видно рівно три шляхи даних, які потрібно контролювати:

#НапрямокЩо відбуваєтьсяНаша дія
1ЛокальноВстановлюємо CurrentGameMode напрямуПеремикаємо по Ctrl+B
2ВхідніСервер надсилає Packet_PlayerMode або Packet_PlayerData, перезаписуючи gameModeПерехоплюємо та форсуємо Creative
3ВихідніКлієнт надсилає RequestModeChange (Id=20) серверуПідміняємо на Survival перед відправкою

Тут розібрано ці три шляхи. Подивімось на фрагменти мода.


Фрагменти мода

Структура проєкту

BHacks/
├── BHacks.csproj          # .NET 8.0, посилання на VintagestoryLib + 0Harmony
├── modinfo.json           # side: "client" — критично для мультиплеєра
├── BHacksModSystem.cs     # Точка входу, обробник Ctrl+B
└── Patches/
    └── ModeChangePatches.cs  # Три Harmony prefix-патчі
{
  "type": "code",
  "modid": "bhacks",
  "name": "BHacks - Creative Mode Toggle",
  "authors": ["Belz"],
  "description": "Press Ctrl+B to toggle creative mode on the client side.",
  "side": "client",
  "version": "1.0.0",
  "dependencies": {
    "game": ""
  }
}
Чому side: client важливий

"side": "client" позначає клієнтський мод. Це стосується завантаження та поширення, а не прав змінювати серверний стан чи гарантії роботи на кожному сервері.

Точка входу

public class BHacksModSystem : ModSystem
{
    private Harmony patcher;
    private ICoreClientAPI clientApi;

    public static bool CreativeModeEnabled = false;
    public static ILogger Log;

    public override void StartClientSide(ICoreClientAPI api)
    {
        clientApi = api;
        Log = api.Logger;

        // Застосовуємо всі Harmony-патчі в категорії "bhacks"
        if (!Harmony.HasAnyPatches(Mod.Info.ModID))
        {
            patcher = new Harmony(Mod.Info.ModID);
            patcher.PatchCategory(Mod.Info.ModID);
        }

        // Сира подія KeyDown — надійніше за систему гарячих клавіш
        api.Event.KeyDown += OnKeyDown;
    }
}

Обробник перемикання

Коли користувач натискає Ctrl+B, ми напряму змінюємо дані світу гравця — без пакета, без запиту, просто присвоєння поля:

private void OnKeyDown(KeyEvent e)
{
    if (e.CtrlPressed && e.KeyCode == (int)GlKeys.B)
    {
        CreativeModeEnabled = !CreativeModeEnabled;

        var player = clientApi.World.Player;
        if (player != null)
        {
            var worldData = player.WorldData;

            if (CreativeModeEnabled)
            {
                worldData.CurrentGameMode = EnumGameMode.Creative;
                worldData.FreeMove = true;
                worldData.NoClip = true;
                player.Entity.Controls.IsFlying = true;
                player.Entity.Controls.NoClip = true;
            }
            else
            {
                worldData.CurrentGameMode = EnumGameMode.Survival;
                worldData.FreeMove = false;
                worldData.NoClip = false;
                player.Entity.Controls.IsFlying = false;
                player.Entity.Controls.NoClip = false;
            }
        }

        e.Handled = true;
    }
}

Клієнт тепер використовує стан Creative. Політ, колізії та ламання блоків можуть змінитися локально; візуальне зникнення блока не доводить, що сервер прийняв і зберіг його видалення.


Три Harmony-патчі

Патч 1: вхідний Packet_PlayerMode

Коли сервер розсилає зміну режиму (пакет Id 46), HandleModeChange викликає UpdateFromPacket(ClientMain, Packet_PlayerMode). Це основний шлях відкату — сервер каже «ти в Survival», і клієнт підкоряється.

Ми перехоплюємо пакет до того, як він потрапить у метод, і перезаписуємо поля:

[HarmonyPrefix()]
[HarmonyPatch(typeof(ClientWorldPlayerData), "UpdateFromPacket",
    new Type[] { typeof(ClientMain), typeof(Packet_PlayerMode) })]
public static void BeforeUpdateFromPacketMode(ClientMain game, Packet_PlayerMode mode)
{
    if (BHacksModSystem.CreativeModeEnabled && mode != null)
    {
        mode.GameMode = 2;   // EnumGameMode.Creative
        mode.FreeMove = 1;
        mode.NoClip = 1;
    }
}

Патч 2: вхідний Packet_PlayerData

Друге перевантаження UpdateFromPacket приймає Packet_PlayerData — більший пакет стану гравця. Це ще один вхідний шлях, здатний перезаписати локальний режим.

Без цього патчу вхідне оновлення стану гравця може відновити локальний режим через окремий шлях коду:

[HarmonyPrefix()]
[HarmonyPatch(typeof(ClientWorldPlayerData), "UpdateFromPacket",
    new Type[] { typeof(ClientMain), typeof(Packet_PlayerData) })]
public static void BeforeUpdateFromPacketData(ClientMain game, Packet_PlayerData packet)
{
    if (BHacksModSystem.CreativeModeEnabled && packet != null)
    {
        packet.GameMode = 2;
        packet.FreeMove = 1;
        packet.NoClip = 1;
    }
}
Чому обидва перевантаження?

Обидва перевантаження можуть оновити локальний режим. Патч лише для Packet_PlayerMode не зачіпає шлях Packet_PlayerData. Жоден із патчів не змінює стан режиму на сервері.

Патч 3: вихідний RequestModeChange

Клієнт періодично надсилає пакети синхронізації режиму — при зміні дальності промальовки (RequestNewViewDistance викликає RequestMode внутрішньо), при зміні налаштувань рендеру або коли внутрішні гарячі клавіші запускають повторну відправку режиму. Щоразу поточні значення gameMode та freeMove серіалізуються в Packet_Client з Id = 20.

Запити режиму цим шляхом повідомляють Survival, хоча локально клієнт використовує Creative. Це розбіжність полів пакета й локального стану, а не гарантія від виявлення чи логування.

Обробка серверних оновлень і вихідних запитів

[HarmonyPrefix()]
[HarmonyPatch(typeof(ClientMain), "SendPacketClient")]
public static void BeforeSendPacketClient(Packet_Client packetClient)
{
    if (packetClient == null) return;

    // Id 20 = RequestModeChange
    if (BHacksModSystem.CreativeModeEnabled
        && packetClient.Id == 20
        && packetClient.RequestModeChange != null)
    {
        packetClient.RequestModeChange.GameMode = 1;  // Survival
        packetClient.RequestModeChange.FreeMove = 0;
        packetClient.RequestModeChange.NoClip = 0;
        packetClient.RequestModeChange.PickingRange =
            CollectibleNet.SerializeFloat(4.5f);       // стандартна дальність Survival
    }
}
Чому переписуємо, а не блокуємо?

Блокування всього пакета відкинуло б і інші поля запиту режиму. Показаний патч змінює вибрані поля; Survival у запиті не доводить, що дії прийняті або непомітні.


Повний потік пакетів

Ось як усі три патчі працюють разом:

Client                                     Server
  |                                          |
  |  [Ctrl+B натиснуто]                      |
  |  CurrentGameMode = Creative              |
  |  FreeMove = true, NoClip = true          |
  |  (пакет не відправлений)                 |
  |                                          |
  |  -- деякий час потому --                 |
  |                                          |
  |  Клієнт намагається відправити:          |
  |  RequestModeChange {                     |
  |    GameMode=2, FreeMove=1                |
  |  }                                       |
  |         |                                |
  |    [Патч 3] підміняє:                    |
  |    GameMode=1, FreeMove=0                |
  |         |                                |
  |  Packet_Client (Id=20)                   |
  |  RequestModeChange {                     |
  |    GameMode=1 (Survival)                 |
  |    FreeMove=0, NoClip=0                  |
  |  }                                       |
  | ---------------------------------------> |
  |                                          |  все нормально, без помилок
  |  Packet_Server (Id=46)                   |
  |  ModeChange {                            |
  |    GameMode=1 (Survival)                 |
  |    FreeMove=0, NoClip=0                  |
  |  }                                       |
  | <--------------------------------------- |
  |         |                                |
  |    [Патч 1] підміняє:                    |
  |    GameMode=2, FreeMove=1, NoClip=1      |
  |         |                                |
  |  Клієнт обробляє:                        |
  |  CurrentGameMode = Creative              |
  |                                          |

Вихідний запит режиму повідомляє Survival, а клієнт використовує Creative. Сервер залишається авторитетним; ці фрагменти не встановлюють, які ефекти зберігаються та які відхиляються іншими перевірками.


Налаштування декомпіляції

Все це вимагало читання внутрішнього коду гри. Ось підхід:

1. Масова декомпіляція з ILSpy CLI — дає повний C#-проєкт з IntelliSense:

dotnet tool install -g ilspycmd
ilspycmd -p -o ./decompiled "%APPDATA%/Vintagestory/VintagestoryLib.dll" \
    -r "%APPDATA%/Vintagestory" \
    -r "%APPDATA%/Vintagestory/Lib"

2. grep для пошуку патернів — швидше, ніж клікати по GUI:

grep -rn "RequestModeChange" ./decompiled/ --include="*.cs"
grep -rn "gameMode\s*=" ./decompiled/ --include="*.cs"

3. Документація Harmony для розуміння семантики prefix/postfix: harmony.pardeike.net

Декомпілюйте як проєкт

Використовуйте прапорець -p в ILSpy CLI. Займає ~10 секунд і дає повний .csproj з IntelliSense у вашій IDE. Набагато швидше, ніж стрибати по GUI-декомпілятору намагаючись простежити ланцюжки викликів.


4. Збірка наявного проєкту

dotnet build BHacks.sln -c Debug

Результат потрапляє в BHacks/bin/Debug/Mods/mod/. Пакуємо BHacks.dll + modinfo.json у zip:

Compress-Archive -Path BHacks/bin/Debug/Mods/mod/BHacks.dll,
    BHacks/bin/Debug/Mods/mod/modinfo.json -DestinationPath BHacks.zip

Для повного проєкту з потрібними збірками гри та залежностями команди вище ілюструють етап збірки. Ці матеріали тут не наведено, тому самі фрагменти не дають відтворюваної збірки.


Що охоплюють фрагменти

Фрагменти показують три Harmony prefix-патчі. Вони змінюють локальний стан і окремі поля пакетів, а не серверні права:

Ціль патчуНапрямокПризначення
UpdateFromPacket(Packet_PlayerMode)ВхіднийФорсувати Creative у пакетах зміни режиму
UpdateFromPacket(Packet_PlayerData)ВхіднийФорсувати Creative у пакетах повних даних гравця
SendPacketClientВихіднийПовідомляти Survival у показаних запитах режиму

Область дії та обмеження:

  1. Показані сетери змінюють локальний стан без запиту режиму.
  2. Патчі змінюють окремі вхідні оновлення та вихідні запити.
  3. Візуальний ефект, прийнята сервером дія та збережена зміна світу — різні результати; останні два цими фрагментами не встановлено.
  4. Harmony дає доступ до всього. Навіть private методи в основній збірці гри можна перехопити за допомогою prefix-хуків — не потрібно модифікувати файли на диску. Схема пакетів умовна: її твердження про те, що вважає кожна сторона, не доводять непомітність або серверні права Creative.

Сервер валідує запити зміни режиму, а показані клієнтські сетери змінюють локальний стан без запиту. Це пояснює локальну поведінку, але не доводить повноцінні серверні права Creative або однаковий результат на різних серверах.

Читайте також

Іншу межу між клієнтом і сервером розбирає стаття про телепортацію: вона присвячена оновленням позиції, а не запитам режиму Creative.

Обидві статті зібрано в розділі теми Vintage Story.

Повідомити про помилку

Знайшли помилку? Напишіть мені в LinkedIn: додайте посилання на сторінку й зазначте, що потрібно виправити.