16.10.2026 24 мин чтения Просмотров:1

Mono против IL2CPP: как устроен моддинг Unity и где он ломается

Почему один и тот же мод для Unity пишется за вечер в Mono-игре и за выходные в IL2CPP-игре. Разбираем загрузчики, Harmony, вырезание кода, обёртки Il2CppInterop и что из этого следует для разработчика.

Mono против IL2CPP: как устроен моддинг Unity и где он ломается

Одна и та же задача, например «показать игроку меню по F1 и удвоить урон», в двух играх на Unity решается совершенно по-разному. В одной это сорок строк кода и вечер работы. В другой первые несколько часов уходят на то, чтобы мод вообще загрузился, потом выясняется, что GUILayout отсутствует, а Input.GetKeyDown бросает исключение. Обе игры сделаны на Unity, но собраны с разным scripting backend: одна на Mono, другая на IL2CPP.

Этот выбор разработчик делает одной галочкой в Project Settings → Player → Other Settings → Scripting Backend, и он определяет почти всё, что потом происходит с моддингом: какими инструментами открывать игру, как перехватывать методы, что вообще доступно из кода мода. Ниже разобраны оба мира: как устроена загрузка модов, где и почему они ломаются и как это обойти. Последний раздел адресован тем, кто сам выпускает игры: что из всего этого следует для вашей сборки.

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

Как понять, на чём собрана игра

Открывать игру в декомпиляторе до того, как определён backend, бессмысленно: инструменты для Mono и IL2CPP разные. Достаточно заглянуть в папку игры.

  • Mono: есть папка <Игра>_Data/Managed/ с файлами Assembly-CSharp.dll, UnityEngine.CoreModule.dll и другими, а рядом с exe лежит папка MonoBleedingEdge.
  • IL2CPP: рядом с exe лежит GameAssembly.dll, а в <Игра>_Data/il2cpp_data/Metadata/ есть файл global-metadata.dat. Папки Managed нет, или в ней только служебные сборки.

Разница принципиальная. В Mono-сборке весь код игры лежит в виде IL, промежуточного байт-кода .NET. ILSpy или dnSpyEx показывают его почти в том же виде, в каком он был написан: с именами классов, полей, локальных переменных (если не было обфускации) и с телами методов. В IL2CPP тот же C# сначала превращён в C++, а затем скомпилирован в машинный код. В GameAssembly.dll лежат инструкции процессора, а в global-metadata.dat хранятся имена типов, методов, полей и строковые литералы. Без этого файла рантайм IL2CPP не смог бы работать с рефлексией, поэтому выкинуть метаданные из сборки нельзя.

Отсюда первое практическое следствие. В Mono вы читаете код игры. В IL2CPP вы читаете восстановленный скелет: Cpp2IL или Il2CppDumper по метаданным строят сборки-заглушки, в которых есть все сигнатуры, но нет тел методов (Cpp2IL умеет частично восстанавливать логику, но читать её заметно тяжелее). Понять, что делает конкретный метод, часто проще в рантайме, через инспектор вроде UnityExplorer, чем в статике.

Загрузчики модов и что они делают на самом деле

Unity не умеет загружать моды. Эту работу берут на себя сторонние загрузчики, и самые распространённые из них BepInEx и MelonLoader. Оба внедряются в процесс через подмену системной библиотеки (обычно winhttp.dll или version.dll) рядом с exe: Windows загружает её раньше настоящей, и загрузчик получает управление до старта движка.

Дальше пути расходятся. Для Mono загрузчику достаточно дождаться инициализации рантайма Mono и загрузить в него сборки модов. Они живут в том же домене, что и Assembly-CSharp.dll, видят те же типы и вызывают их напрямую. Мод для Mono-игры по сути ничем не отличается от обычного скрипта проекта, только подключается снаружи.

С IL2CPP так не выйдет: в процессе нет управляемого рантайма, в котором мог бы выполниться C# мода. Загрузчик поднимает свой рантайм .NET (в BepInEx 6 и MelonLoader 0.6 это CoreCLR) и генерирует для игры сборки-обёртки с помощью Il2CppInterop. Каждый класс игры получает управляемый двойник, у которого вместо полей и тел методов стоят вызовы в нативный код через указатели. Когда мод пишет player.health = 100, на деле происходит вызов функции IL2CPP-рантайма, которая пишет значение по смещению в нативном объекте.

Эти обёртки генерируются автоматически при первом запуске игры с загрузчиком и перегенерируются после обновления игры. Генератор не идеален, и именно поэтому в IL2CPP приходится проверять не только свой мод, но и прослойку под ним.

Минимальный мод для Mono на BepInEx и Harmony

В Mono-игре мод — это библиотека классов .NET (обычно netstandard2.0 или net472, в зависимости от версии Unity игры), которая ссылается на BepInEx.dll, 0Harmony.dll, Assembly-CSharp.dll и нужные модули UnityEngine.* из папки Managed. Точка входа — класс, унаследованный от BaseUnityPlugin. Он и есть MonoBehaviour, поэтому в нём работают Update, LateUpdate и OnGUI.

using BepInEx;
using BepInEx.Configuration;
using HarmonyLib;
using UnityEngine;

[BepInPlugin(PluginGuid, "Damage Tweaks", "1.0.0")]
public class DamageTweaksPlugin : BaseUnityPlugin
{
    public const string PluginGuid = "ru.unitystore.damagetweaks";

    // Настройки попадают в BepInEx/config/ru.unitystore.damagetweaks.cfg,// их можно менять без перекомпиляции.internal static ConfigEntry<float> DamageMultiplier;
    internal static ConfigEntry<KeyCode> MenuKey;

    private Harmony _harmony;
    private bool _menuVisible;

    private void Awake()
    {
        DamageMultiplier = Config.Bind("General", "DamageMultiplier", 2f,
            "Множитель урона, который наносит игрок");
        MenuKey = Config.Bind("General", "MenuKey", KeyCode.F1,
            "Клавиша показа меню");

        _harmony = new Harmony(PluginGuid);
        _harmony.PatchAll(typeof(DamageTweaksPlugin).Assembly);
        Logger.LogInfo("Damage Tweaks загружен");
    }

    private void Update()
    {
        if (Input.GetKeyDown(MenuKey.Value))
            _menuVisible = !_menuVisible;
    }

    private void OnGUI()
    {
        if (!_menuVisible) return;

        GUILayout.BeginArea(new Rect(20, 20, 260, 120), GUI.skin.box);
        GUILayout.Label($"Множитель урона: {DamageMultiplier.Value:0.0}");
        DamageMultiplier.Value = GUILayout.HorizontalSlider(DamageMultiplier.Value, 1f, 10f);
        GUILayout.EndArea();
    }

    private void OnDestroy() => _harmony?.UnpatchSelf();
}

Сам перехват делает Harmony. Он не меняет файл сборки, а на лету переписывает метод в памяти: в начало вставляется вызов вашего Prefix, в конец вызов Postfix. Prefix получает аргументы оригинала по имени и может изменить их через ref или вовсе отменить вызов оригинала, вернув false. Postfix видит результат через параметр __result и может его подменить.

using HarmonyLib;

// Health и HitInfo — условные классы игры, их имена берутся из декомпилятора.
[HarmonyPatch(typeof(Health), nameof(Health.ApplyDamage))]
internal static class ApplyDamagePatch
{
    // __instance — объект, на котором вызван метод.// hit передан по ссылке, поэтому изменения дойдут до оригинала.private static void Prefix(Health __instance, ref HitInfo hit)
    {
        // Урон по самому игроку не трогаем, усиливаем только исходящий.if (hit.Attacker != null && hit.Attacker.IsLocalPlayer)
            hit.Damage *= DamageTweaksPlugin.DamageMultiplier.Value;
    }
}

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

Где моды ломаются в Mono

Mono кажется простым режимом, но и здесь есть ловушки, причём они не технические, а логические: код читается легко, и поэтому легко поверить тому, что в нём написано.

Метод с правильным именем, который никто не вызывает

В крупных проектах остаётся много мёртвого кода: старые версии механик, методы для отладки, заготовки, от которых отказались. Метод Player.CanMove() выглядит идеальной точкой для патча «заморозить управление, пока открыто меню мода», но может оказаться, что движение давно проверяет другой метод, а этот остался от прототипа. Патч ставится без ошибок и ничего не делает.

Прежде чем писать логику патча, убедитесь, что метод вызывается: поставьте пустой Postfix с логом и посмотрите, появляется ли запись. В dnSpyEx и ILSpy есть поиск использований (Analyze → Used By): если у метода нет вызывающих в Assembly-CSharp, его, скорее всего, дёргает только рефлексия, анимационное событие или никто.

Патч, который падает вместе с игрой после обновления

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

using System;
using System.Reflection;
using BepInEx.Logging;
using HarmonyLib;

internal static class SafePatcher
{
    public static bool TryPatch(Harmony harmony, ManualLogSource log,
        Type type, string methodName, Type[] args = null,
        Type patchClass = null, string prefix = null, string postfix = null)
    {
        MethodInfo original = AccessTools.Method(type, methodName, args);
        if (original == null)
        {
            // Мод продолжит работать без этой функции,// а пользователь увидит в логе, что именно отвалилось.
            log.LogWarning($"Не найден {type.Name}.{methodName}, функция отключена");
            return false;
        }

        harmony.Patch(original,
            prefix: prefix != null ? new HarmonyMethod(patchClass, prefix) : null,
            postfix: postfix != null ? new HarmonyMethod(patchClass, postfix) : null);
        return true;
    }
}

Встроенные отладочные функции с побочными эффектами

Во многих играх остаются консольные команды разработчиков: режим бессмертия, полёт, выдача предметов. Использовать их заманчиво, но они писались для внутреннего тестирования и часто делают больше, чем кажется. Например, ставят флаг, который синхронизируется по сети, пишется в сохранение или отключает достижения. Свой Prefix на метод получения урона, который отменяет вызов только для локального игрока, обычно чище, чем вызов отладочного SetGodMode(true): вы точно знаете, что он делает и чего не делает.

Приватные поля и публичный API, рассчитанный на игрока

Публичные методы игры написаны под игровые сценарии. Метод «изучить рецепт» показывает уведомление, проигрывает звук и, возможно, пишет в журнал, и это правильно, когда игрок находит один рецепт. Если вызвать его в цикле для двухсот рецептов, на экране окажется двести всплывающих окон. Для массовых операций приходится работать с внутренним состоянием напрямую, а потом вручную вызывать то, что обычно вызывается побочно: обновление кэша интерфейса, пересчёт доступных построек.

Для доступа к приватным полям в Harmony есть AccessTools.FieldRefAccess. Он один раз строит делегат, который возвращает ссылку на поле, и работает на порядок быстрее, чем FieldInfo.GetValue в каждом кадре:

using System.Collections.Generic;
using HarmonyLib;

internal static class RecipeBookAccess
{
    // RecipeBook и его приватное поле m_known — условные, из декомпилятора.private static readonly AccessTools.FieldRef<RecipeBook, HashSet<string>> KnownRef =
        AccessTools.FieldRefAccess<RecipeBook, HashSet<string>>("m_known");

    public static void UnlockAll(RecipeBook book, IEnumerable<string> ids)
    {
        HashSet<string> known = KnownRef(book);
        foreach (string id in ids)
            known.Add(id); // без уведомлений и звуков

        // То, что публичный метод делал бы сам после каждого рецепта,// вызываем один раз в конце.
        book.RefreshUi();
    }
}

Где моды ломаются в IL2CPP

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

Загрузчик не стартует из-за сгенерированной сборки

Иногда игра с MelonLoader или BepInEx 6 падает ещё до загрузки модов, а в логе ошибка о дублирующемся типе или некорректной сборке в одном из сгенерированных UnityEngine.*.dll. Причина в самом генераторе обёрток: компилятор C# создаёт служебные типы с именами вроде <>O или <>c для кэширования делегатов и лямбд, и при восстановлении сборок из метаданных они иногда дублируются или собираются некорректно.

Первое, что стоит сделать, — обновить загрузчик: такие ошибки обычно чинят в Il2CppInterop. Если свежей версии нет, сборку можно «отремонтировать» Mono.Cecil: прочитать её целиком в память, удалить дубликаты и записать заново. Удобно держать для этого маленькую утилиту, которая проверяет хеш файла и запускается после каждого обновления игры, потому что загрузчик перегенерирует обёртки и ошибка возвращается. Сравнивать по хешу надёжнее, чем по дате изменения: дата меняется при каждом копировании.

using System.IO;
using System.Linq;
using System.Security.Cryptography;
using Mono.Cecil;

internal static class AssemblyRepair
{
    public static void Repair(string path)
    {
        // ReadingMode.Immediate читает сборку целиком,// иначе Cecil будет держать файл открытым и не даст его перезаписать.var parameters = new ReaderParameters { ReadingMode = ReadingMode.Immediate };
        using var assembly = AssemblyDefinition.ReadAssembly(path, parameters);
        ModuleDefinition module = assembly.MainModule;

        foreach (TypeDefinition type in module.Types.ToList())
        {
            // Оставляем первый вложенный тип с каждым именем, остальные удаляем.var duplicates = type.NestedTypes
                .GroupBy(t => t.Name)
                .SelectMany(g => g.Skip(1))
                .ToList();
            foreach (TypeDefinition dup in duplicates)
                type.NestedTypes.Remove(dup);
        }

        assembly.Write(path + ".fixed");
        File.Copy(path, path + ".bak", overwrite: true);
        File.Move(path + ".fixed", path, overwrite: true);
    }

    public static string Sha256(string path)
    {
        using var sha = SHA256.Create();
        using FileStream stream = File.OpenRead(path);
        return string.Concat(sha.ComputeHash(stream).Select(b => b.ToString("x2")));
    }
}

Это крайняя мера, и её стоит воспринимать как временную заплатку до исправления в загрузчике. Удалить «не тот» дубликат вполне возможно, поэтому бэкап обязателен.

Нет GUILayout и других «очевидных» API

При сборке IL2CPP Unity прогоняет код через UnityLinker: он вырезает классы и методы, на которые нет ссылок. Уровень задаётся в Project Settings → Player → Optimization → Managed Stripping Level, а опция Strip Engine Code позволяет выкидывать целые модули движка. Если в игре интерфейс полностью на uGUI или UI Toolkit и никто не вызывал GUILayout, его реализации в GameAssembly.dll просто нет. Обёртка при этом может сгенерироваться, ведь метаданные о типе остались, но вызов упадёт.

Мод не может вернуть вырезанный код: машинных инструкций для него не существует. Остаётся пользоваться тем, что уцелело. Обычно это низкоуровневые GUI.Box, GUI.Label и GUI.Button с явными прямоугольниками: их чаще использует сам движок, и они выживают при вырезании. Раскладку приходится считать самому:

using UnityEngine;

internal sealed class SimpleMenu
{
    private const float Width = 260f;
    private const float RowHeight = 24f;
    private const float Padding = 8f;

    private Rect _area;
    private float _cursorY;

    public void Begin(float x, float y, int rows, string title)
    {
        float height = Padding * 2 + RowHeight * (rows + 1);
        _area = new Rect(x, y, Width, height);
        GUI.Box(_area, title);
        _cursorY = y + Padding + RowHeight; // первая строка под заголовком
    }

    // Строка «название: значение» без GUILayout.public void Row(string label, string value)
    {
        var rect = new Rect(_area.x + Padding, _cursorY, Width - Padding * 2, RowHeight);
        GUI.Label(rect, $"{label}: {value}");
        _cursorY += RowHeight;
    }

    public bool Toggle(string label, bool state)
    {
        var rect = new Rect(_area.x + Padding, _cursorY, Width - Padding * 2, RowHeight);
        _cursorY += RowHeight;
        return GUI.Button(rect, $"{(state ? "[x]" : "[ ]")} {label}") ? !state : state;
    }
}

Если и GUI вырезан целиком, остаётся создать своё окно на uGUI: Canvas, Text и Button почти наверняка есть, раз игра ими пользуется. Кода получается больше, но этот вариант переживает любые настройки вырезания.

Input.GetKeyDown бросает исключение

Похожая история со вводом. Если в Player → Active Input Handling выбран только новый Input System, старый класс UnityEngine.Input в сборке есть, но любой вызов бросает InvalidOperationException. Это поведение самого движка, а не следствие IL2CPP: в Mono-игре с новым Input System будет так же. Просто в IL2CPP-играх, особенно свежих, новый Input System встречается чаще.

Вариантов два. Первый — воспользоваться самим Input System, раз он в игре есть: Keyboard.current[Key.F1].wasPressedThisFrame. Через обёртки это обычно работает, но зависит от того, какие части пакета уцелели. Второй вариант надёжнее и не зависит от Unity вовсе: спрашивать состояние клавиш у Windows через GetAsyncKeyState и самому отслеживать момент нажатия.

using System.Collections.Generic;
using System.Runtime.InteropServices;

internal sealed class KeyWatcher
{
    [DllImport("user32.dll")]
    private static extern short GetAsyncKeyState(int virtualKey);

    public const int VK_F1 = 0x70;
    public const int VK_F2 = 0x71;

    private readonly HashSet<int> _held = new HashSet<int>();

    // true только в первый кадр нажатия, как у Input.GetKeyDown.public bool WasPressed(int virtualKey)
    {
        // Старший бит означает «клавиша нажата прямо сейчас».bool down = (GetAsyncKeyState(virtualKey) & 0x8000) != 0;

        if (down)
            return _held.Add(virtualKey); // Add вернёт false, если клавиша уже была зажата

        _held.Remove(virtualKey);
        return false;
    }
}

У GetAsyncKeyState есть особенность: он не знает, в фокусе ли окно игры. Нажатие F1 в браузере поверх игры тоже сработает. Если это мешает, добавьте проверку Application.isFocused перед опросом.

Свои компоненты нужно регистрировать в IL2CPP

В Mono мод может повесить на объект свой MonoBehaviour через AddComponent<T>(). В IL2CPP рантайм игры ничего не знает о классах мода, поэтому тип сначала нужно зарегистрировать через ClassInjector.RegisterTypeInIl2Cpp<T>(), а у класса должен быть конструктор, принимающий указатель на нативный объект:

using System;
using Il2CppInterop.Runtime.Injection;
using MelonLoader;
using UnityEngine;

[assembly: MelonInfo(typeof(TrainerMod), "Trainer", "1.0.0", "unitystore.ru")]

public class TrainerMod : MelonMod
{
    public override void OnInitializeMelon()
    {
        // Без регистрации AddComponent упадёт: IL2CPP не знает этот тип.
        ClassInjector.RegisterTypeInIl2Cpp<TrainerBehaviour>();
    }

    private GameObject _host;

    public override void OnSceneWasInitialized(int buildIndex, string sceneName)
    {
        // Объект переживает смену сцен, поэтому создаём его один раз.if (_host != null) return;

        var host = new GameObject("TrainerHost");
        _host = host;
        UnityEngine.Object.DontDestroyOnLoad(host);
        host.AddComponent<TrainerBehaviour>();
    }
}

public class TrainerBehaviour : MonoBehaviour
{
    // Этот конструктор вызывает Il2CppInterop при создании обёртки// над уже существующим нативным объектом.public TrainerBehaviour(IntPtr ptr) : base(ptr) { }

    private void LateUpdate()
    {
        // Логика мода
    }
}

Для простых модов компонент не нужен: у MelonMod есть свои OnUpdate, OnLateUpdate и OnGUI. Регистрация пригодится, когда поведение нужно привязать к конкретному объекту сцены, например к каждому врагу.

Коллекции и приведение типов

Ещё одна частая ошибка новичков в IL2CPP — ожидать, что List<Enemy> из игры станет обычным System.Collections.Generic.List. Это будет Il2CppSystem.Collections.Generic.List<Enemy>: обёртка над нативным списком со своими методами. Привычный LINQ к ней не применить, а is и as между типами игры работают не так, как ожидается, потому что проверяется тип обёртки, а не нативного объекта. Для приведения используйте TryCast<T>(), для получения Type в API Unity — Il2CppType.Of<T>().

Почему значения не удерживаются

Эта проблема одинаково встречается в обоих мирах, но в IL2CPP её сложнее диагностировать, потому что нельзя просто прочитать метод пересчёта. Мод записывает игроку скорость 20, через кадр она снова 5. Игра не «сопротивляется» моду. Чаще всего характеристики персонажа хранятся в двух видах: базовые значения и итоговые, которые каждый кадр пересчитываются из базовых, экипировки и эффектов. Мод пишет в итоговое поле, а Update его перетирает.

Решений три, от грубого к точному.

  1. Перезаписывать в LateUpdate. Все Update всех объектов в кадре выполняются раньше любого LateUpdate, поэтому запись в LateUpdate переживёт пересчёт и будет видна при отрисовке. Работает почти всегда, но не спасает, если игра читает значение внутри Update до того, как вы его записали, или сама пересчитывает его в LateUpdate или в FixedUpdate для физики.
  2. Postfix на метод пересчёта. Если найден метод вроде RecalculateStats, Postfix на него правит итог сразу после расчёта, в тот же момент и ровно столько раз, сколько нужно.
  3. Postfix на геттер. Если игра читает значение через свойство (MoveSpeed { get; }), можно подменить сам результат чтения. Поле остаётся честным, а все потребители видят изменённое значение. Это самый аккуратный вариант, но в IL2CPP мелкие геттеры часто встроены компилятором в вызывающий код, и патч на них не срабатывает.

Отдельно продумайте выключение функции. Если мод перезаписывал скорость, а потом пользователь снял галочку, правильное поведение — вернуть то значение, которое посчитала бы игра, а не «то, что было в момент включения»: за это время персонаж мог сменить экипировку. Поэтому при подходе через LateUpdate удобно хранить не исходное значение, а флаг. Когда флаг снят, мод просто ничего не пишет, и на следующем кадре игра сама восстанавливает честный итог.

using UnityEngine;

internal sealed class SpeedOverride
{
    public bool Enabled;
    public float Multiplier = 2f;

    // Вызывается из LateUpdate мода.public void Apply(PlayerStats stats)
    {
        if (!Enabled || stats == null)
            return; // ничего не трогаем: игра сама пересчитает честное значение

        // Умножаем то, что игра посчитала в этом кадре,// а не ставим константу: так учитываются бафы и дебафы.
        stats.currentMoveSpeed = stats.currentMoveSpeed * Multiplier;
    }
}

Здесь есть тонкость: если игра не пересчитывает поле каждый кадр, а только при изменении экипировки, умножение в каждом LateUpdate будет копиться, и скорость улетит в бесконечность. Прежде чем выбирать между «умножить» и «присвоить», проверьте логом, меняется ли значение между кадрами само.

Интероп стоит денег

В Mono обращение мода к полю игры — это обычное чтение памяти. В IL2CPP каждое обращение к свойству или полю объекта игры проходит через обёртку: получить указатель, вызвать функцию рантайма, при необходимости создать управляемый объект-обёртку для результата. По отдельности это микросекунды, но мод, который каждый кадр перебирает всех врагов на уровне, читает у каждого позицию, здоровье и состояние и вызывает FindObjectsOfType, легко съедает несколько миллисекунд кадра. На 60 FPS бюджет кадра около 16,7 мс, и такая просадка заметна.

Правила простые и подходят не только для модов:

  • Не ищите объекты каждый кадр. FindObjectsOfType дорог и в обычном проекте. Обновляйте список раз в 0,25–0,5 секунды или по событию загрузки сцены.
  • Действия, которые не обязаны быть покадровыми (лечение, восполнение ресурсов, массовые изменения), выполняйте по таймеру.
  • Кэшируйте всё, что не меняется: ссылку на игрока, компоненты, результаты Il2CppType.Of<T>().
  • Перед перебором копируйте список. Если в цикле враг погибнет, игра удалит его из своей коллекции, и итерация по живому списку упадёт или пропустит элементы.
using System.Collections.Generic;
using Il2CppInterop.Runtime;
using UnityEngine;

internal sealed class EnemyScanner
{
    private const float ScanInterval = 0.25f;

    private readonly List<Enemy> _cache = new List<Enemy>();
    private float _nextScan;

    public IReadOnlyList<Enemy> Enemies => _cache;

    public void Tick()
    {
        if (Time.unscaledTime < _nextScan)
            return;
        _nextScan = Time.unscaledTime + ScanInterval;

        _cache.Clear();
        // Копируем в свой список один раз за интервал,// дальше мод работает только с ним.foreach (UnityEngine.Object obj in Object.FindObjectsOfType(Il2CppType.Of<Enemy>()))
        {
            Enemy enemy = obj.TryCast<Enemy>();
            if (enemy != null && enemy.isAlive && !enemy.isProp)
                _cache.Add(enemy);
        }
    }
}

Флаг isProp здесь не случаен: во многих играх разрушаемые бочки, ящики и декорации реализованы тем же классом «существа с здоровьем», что и враги. Функция «убить всех» без такого фильтра сносит пол-уровня.

С той же функцией связана ещё одна ловушка. Вызвать у врага метод Die() напрямую кажется самым коротким путём, но смерть в игре обычно запускается из обработчика урона, и именно там начисляются опыт, лут и прогресс заданий. Прямой вызов Die() убирает врага, но награды не будет. Нанести урон, равный остатку здоровья, через тот же метод, которым пользуется оружие игрока, надёжнее: сработает вся цепочка.

Ищите узкое место, через которое проходит логика

Из всех разобранных случаев складывается общий принцип: патчить нужно не там, где эффект виден, а там, где он рождается. Бессмертие лучше делать не записью здоровья каждый кадр, а Prefix на единственный метод, через который проходит весь урон. Блокировку управления — не поиском всех методов движения, а патчем на проверку «открыт ли интерфейс», которую игра и так использует, чтобы персонаж не бегал при открытом инвентаре. Опыт — через метод начисления, а не через поле уровня.

Такая точка обычно лежит ниже, чем кажется по именам. Метод Player.TakeDamage может оказаться тонкой обёрткой, которая вызывает Character.ApplyDamage, а та уже обрабатывает броню, сопротивления и смерть. Чтобы найти её в Mono, достаточно пройтись по цепочке вызовов в декомпиляторе. В IL2CPP помогает UnityExplorer: в нём можно посмотреть живые объекты сцены, их поля и вызвать методы прямо в рантайме, а потом поставить временные Postfix с логом на несколько кандидатов и посмотреть, какой срабатывает на каждый удар.

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

Сохранения в PlayerPrefs

Небольшие игры, особенно демоверсии, часто хранят прогресс в PlayerPrefs. На Windows это реестр, раздел HKEY_CURRENT_USER\Software\<Company Name>\<Product Name>, где оба имени берутся из Player Settings игры. Unity добавляет к имени каждого ключа суффикс _h и числовой хеш, поэтому ключ unlocked_levels в реестре выглядит как unlocked_levels_h1234567890. Сами значения хранятся в двоичном виде, и строки нельзя просто поправить в regedit как текст.

Безопаснее всего править такие сохранения изнутри игры: мод вызывает те же PlayerPrefs.GetInt, SetInt и Save, что и игра, и не нужно разбираться с форматом. Если же править снаружи, то только при закрытой игре (она держит значения в памяти и при выходе перезапишет ваши правки) и только после экспорта раздела реестра в .reg-файл.

Список допустимых значений, например идентификаторы уровней или персонажей, в IL2CPP-игре можно найти среди строковых литералов в global-metadata.dat: туда попадают все строки, которые есть в коде. Здесь есть полезное следствие: если строк, связанных с какой-то механикой, в метаданных нет вовсе, этой механики, скорее всего, нет и в сборке. Так легко отличить «заблокированных персонажей», которые можно открыть, от тех, что в демоверсию просто не вошли.

Если вы делаете игру: что из этого следует

Всё сказанное выше полезно не только тем, кто пишет моды. Это ещё и честная картина того, что видят в вашей сборке посторонние.

IL2CPP не защищает код

Распространённое мнение «перейдём на IL2CPP, и игру не взломают» неверно. IL2CPP заметно усложняет чтение логики, но имена всех классов, методов и полей, а также все строковые литералы лежат в global-metadata.dat в открытом виде, и инструменты восстанавливают по ним структуру проекта за минуты. Загрузчики модов для IL2CPP работают с большинством игр без какой-либо подготовки со стороны разработчика. Выбирать IL2CPP стоит из-за производительности и требований платформ (для iOS, WebGL и консолей другого варианта нет), а не ради защиты. О том, как IL2CPP и Burst влияют на производительность низкоуровневого кода, подробнее рассказано в статье про Span<T>, stackalloc и ref struct.

Если игра с мультиплеером, единственная настоящая защита — авторитетный сервер. Всё, что решает клиент (урон, лут, валюта, скорость передвижения), рано или поздно будет подменено. Клиент должен присылать намерения («ударил вот этого»), а сервер решать, что из этого получилось. Обфускаторы и шифрование метаданных поднимают порог входа, но не меняют сути.

Вырезание кода влияет и на ваши собственные плагины

Проблема с отсутствующим GUILayout касается не только модов. Если в проекте есть код, который обращается к типам через рефлексию (сериализаторы, DI-контейнеры, плагины, загружаемые по имени), UnityLinker может вырезать то, что на самом деле нужно, и ошибка появится только в релизной IL2CPP-сборке. Защитить нужные сборки и типы можно файлом link.xml в папке Assets:

<linker><!-- Сохранить сборку целиком --><assembly fullname="MyGame.Serialization" preserve="all"/>

  <!-- Сохранить отдельный тип, который создаётся через рефлексию --><assembly fullname="Assembly-CSharp"><type fullname="MyGame.SaveSystem.SaveDataV2" preserve="all"/></assembly>
</linker>

Для отдельных классов и методов есть атрибут [UnityEngine.Scripting.Preserve]. Проверять это нужно на реальной IL2CPP-сборке с тем уровнем вырезания, который пойдёт в релиз: в редакторе всегда работает Mono, и там таких ошибок не бывает.

Если моды вам нужны

Для многих жанров (симуляторы, выживание, стратегии) моды продлевают жизнь игре на годы. Если хотите это поддержать, на ПК оставить Mono — разумный выбор: моддеры получают читаемый код, и вам почти ничего не нужно делать. Следующий шаг — помочь им архитектурой, а не документацией. Узкие точки, которые моддеры ищут вручную, можно сделать явными: события OnDamageCalculated или OnStatsRecalculated, на которые можно подписаться, данные предметов и рецептов в ScriptableObject или JSON, а не в коде, загрузка дополнительного контента через AssetBundles или Addressables из папки модов. Код, разбитый так, легче поддерживать и самим: те же точки расширения нужны для DLC и балансных правок.

И ещё одна деталь, о которой редко думают. Чем меньше в релизной сборке отладочных команд, которые меняют сетевое состояние или пишут в сохранение, тем меньше способов случайно испортить игру себе и другим. Отладочные функции лучше заворачивать в #if DEVELOPMENT_BUILD || UNITY_EDITOR, чтобы их не было в релизе.

С чего начать

Если хотите написать первый мод для одиночной игры:

  1. Определите backend по папке игры: Managed/Assembly-CSharp.dll означает Mono, GameAssembly.dll и global-metadata.dat означают IL2CPP.
  2. Для Mono возьмите BepInEx 5 и ILSpy или dnSpyEx. Для IL2CPP возьмите MelonLoader или BepInEx 6, а также UnityExplorer для изучения игры в рантайме.
  3. Прежде чем писать логику, поставьте пустой патч с логом и убедитесь, что метод действительно вызывается.
  4. Ищите одну точку, через которую проходит механика, а не место, где виден результат.
  5. Если значение не держится, проверьте, кто и когда его пересчитывает, и выберите между LateUpdate, Postfix на пересчёт и Postfix на геттер.
  6. В IL2CPP опрашивайте игру по таймеру и кэшируйте всё, что можно.
  7. Делайте бэкап сохранений и отключайте функции мода, если игра в сетевой сессии.

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

Комментарии

Чтобы оставить комментарий, войдите в аккаунт.

Пока нет комментариев. Будьте первым!

Смотрите также