Вход на сайт

Просмотр новости

Найдите то, что Вас интересует

Два читмода за выходные: Mono против IL2CPP и восемь мест, где ломается моддинг Unity

Дата публикации: 26-09-2026 11:10:38

Поставил MelonLoader в демку на Unity 6, собрал мод — и он молчит: BadImageFormatException: Duplicate type with name '<>O'. За выходные я написал два чит-мода с почти одинаковым ТЗ: меню для Valheim (Mono, BepInEx) и трейнер для демки Go Next! (IL2CPP, MelonLoader). Код получился настолько разный, что набралось восемь мест, где всё ломается именно из-за отсутствия managed-кода: битая сгенерированная сборка, вырезанный GUILayout, падающий Input.GetKeyDown, цена интеропа. Плюс разбор того, где Unity на самом деле держит PlayerPrefs и почему SetGodMode() хуже собственного патча. Читать далее

Основное содержимое страницы с новостью.

Уровень сложностиСредний

Время на прочтение12 мин

Охват и читатели3.7K

Кейс

Я поставил MelonLoader в демку на Unity 6, собрал мод в три строчки, запустил игру — и мод молчит. В логе:

BadImageFormatException: Duplicate type with name '<>O'
[ERROR] No Support Module Loaded!

Ни один хоткей не работает, OnUpdate() не вызывается ни разу. Причина оказалась в одной сгенерированной сборке из ста двадцати восьми: UnityEngine.CoreModule.dll, которую Il2CppInterop выплюнул с битыми записями типов. Рантайм её отказался грузить, без неё MelonLoader не выдаёт модам игровой цикл — и мод существует, но не живёт.

За выходные я написал два чит-мода: меню для Valheim (Mono, BepInEx) и трейнер для демки Go Next! (IL2CPP, MelonLoader). Задачи почти одинаковые: бессмертие, ресурсы, разблокировка прогресса, оверлей с хоткеями. Код получился настолько разный, что имеет смысл разобрать по пунктам — что именно ломается, когда у игры нет managed-кода.

Пишу бэкенд на Java/Kotlin, в Unity до этого лез только со стороны разработчика — делал гоночную игру и выкладывал её в Steam. Разбирать чужую сборку — принципиально другой опыт, и большая часть граблей в статье собрана именно из-за этого.

Оба мода — сингловые. Про мультиплеер отдельный раздел в конце, там всё не так весело.

Два загрузчика, две игры

Valheim

Go Next! Demo

Бэкенд скриптов

Mono

IL2CPP

Unity

6000.0.75

6000.4

Загрузчик

BepInEx 5.4.23

MelonLoader 0.7.3

Патчинг

Harmony, префиксы/постфиксы

прямые вызовы через сгенерированные обёртки

Что видит мод

настоящие assembly_valheim.dll и типы игры

обёртки, сгенерированные из GameAssembly.dll

Сборка мода

csc.exe из Windows, C# 5

.NET SDK 8, net6.0

Размер кода

~1500 строк

~390 строк

Разница в одну строку таблицы — «Mono или IL2CPP» — определяет всё остальное.

В Mono-сборке игра приезжает как обычные .NET-сборки. Их открывает dnSpy, в них видны имена приватных полей, туда штатно ставится Harmony. assembly_valheim.dll — это буквально исходники игры с точностью до имён локальных переменных.

В IL2CPP C#-код транслируется в C++ и компилируется в нативный GameAssembly.dll. Managed-сборок нет. Есть global-metadata.dat — файл с именами типов, методов, полей и строковыми литералами, по которому рантайм связывает нативный код обратно с метаданными. Il2CppInterop (внутри MelonLoader) читает эти метаданные и генерирует managed-обёртки: Il2Cpp.PlayerStats, Il2Cpp.EnemyRobot и так далее. Мод пишется против них, а вызовы уходят в нативный код.

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

1. Загрузчик не грузится (IL2CPP)

Тот самый Duplicate type with name '<>O'. <>O — это имя, которое компилятор C# даёт классу-хранилищу кешированных делегатов. Генератор обёрток умудрился положить в метаданные две записи с одинаковым именем, и рантайм такую сборку не принимает.

Патчить руками нечего: файл генерируется автоматически при первом запуске. Лечится тем, что сборку надо перечитать и записать заново — Mono.Cecil строит метаданные из своей объектной модели, дублей туда просто неоткуда взяться:

Add-Type -Path .\Mono.Cecil.dll

$rp  = New-Object Mono.Cecil.ReaderParameters
$res = New-Object Mono.Cecil.DefaultAssemblyResolver
$res.AddSearchDirectory($Il2Dir)
$rp.AssemblyResolver = $res
$rp.ReadingMode = [Mono.Cecil.ReadingMode]::Immediate

$asm = [Mono.Cecil.AssemblyDefinition]::ReadAssembly($original, $rp)
try     { $asm.Write($tmp) }
finally { $asm.Dispose() }

Читаем, пишем, копируем на место. Всё.

Две детали, которые стоили лишнего времени.

ReadingMode::Immediate обязателен. По умолчанию Cecil читает лениво и держит файл открытым — а мы пишем туда же, откуда читаем.

Состояние («починено / не починено») нельзя определять по времени модификации файла. После починки цель всегда новее, чем .orig, и по таймстемпам починенная сборка неотличима от свежесгенерированной. Первая версия скрипта на этом честно ошиблась: сравнивала даты, видела «цель новее», решала, что всё в порядке, и пропускала работу — при том что игру только что обновили и файл был снова битый. Пришлось писать рядом хеш:

$stamp = Join-Path $Il2Dir 'UnityEngine.CoreModule.fixed.sha256'
if ((Test-Path $stamp) -and ((Get-Content $stamp -Raw).Trim() -eq (Get-Sha256 $Target))) {
    # уже починено
}

И главное: это надо повторять после каждого обновления игры в Steam. Игру патчат, MelonLoader перегенерирует обёртки, дефект возвращается.

2. GUILayout в сборке нет (IL2CPP)

Оверлей я по привычке начал писать на GUILayout.BeginArea / GUILayout.Label. В логе — Method unstripping failed.

Unity при сборке выкидывает из движка код, который игра не использует. Игра рисует свой UI на uGUI и никогда не трогает IMGUI-раскладку — значит, GUILayout в билд не попал. Il2CppInterop пытается «разстрипить» такие методы обратно и здесь не смог.

Работает то, что осталось: GUI.Box, GUI.Label и явные прямоугольники. То есть верстать оверлей приходится в пикселях, руками:

private const int PanelW = 340;
private const int LineH  = 19;
private int _y;

private void Line(string text, Color color)
{
    GUI.contentColor = color;
    GUI.Label(new Rect(22, _y, PanelW - 20, LineH), text);
    _y += LineH;
}

Высоту панели считаю заранее, по числу строк, которые собираюсь нарисовать. Ощущения — как от вёрстки таблиц в 2003-м, но это ровно десять строк кода, и они не зависят ни от чего в игре.

В Valheim, для сравнения, GUILayout на месте, и меню в 890 строк написано нормальными вертикальными и горизонтальными группами.

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

Игра собрана на новой Input System, а в ней штатно отключается legacy-ввод. UnityEngine.Input.GetKeyDown в этом режиме не возвращает false, а падает.

Можно подтянуть Unity.InputSystem через обёртки. Я пошёл короче — дёргаю ввод прямо из WinAPI, минуя движок:

[DllImport("user32.dll")]
private static extern short GetAsyncKeyState(int vKey);

private static readonly HashSet<int> Held = new HashSet<int>();

/// <summary>Срабатывает один раз на нажатие, а не каждый кадр.</summary>
public static bool Down(int vk)
{
    bool now = (GetAsyncKeyState(vk) & 0x8000) != 0;
    if (now)
    {
        if (Held.Contains(vk)) return false;
        Held.Add(vk);
        return true;
    }
    Held.Remove(vk);
    return false;
}

GetAsyncKeyState возвращает состояние «нажата сейчас», а не «нажали в этом кадре». Edge detection — свой, через HashSet удерживаемых клавиш. Тридцать строк, ноль зависимостей от того, что там движок решил про ввод.

Побочный эффект: хоткеи работают, даже когда окно игры не в фокусе. Для трейнера это скорее плюс, для чего-то другого — сюрприз.

В Valheim ввод читается через Keyboard.current из Input System, с разбором комбинаций вида LeftShift+F6 из конфига. Там это просто работает.

4. Записал значение — оно не удержалось (IL2CPP)

Наивная версия: по нажатию F2 выставить damageMult = 50. Работает ровно до следующего кадра — система предметов в игре пересчитывает статы в своём Update, складывая бонусы от предметов, и затирает всё, что туда положили снаружи.

Поэтому значения переписываются каждый кадр, в LateUpdate — то есть после игрового Update:

public override void OnLateUpdate()
{
    if (_netBlocked) return;

    if (_god.On) GameHealth.GodMode = true;
    else if (GameHealth.GodMode) GameHealth.GodMode = false;

    var st = SafeStats();
    if (st == null) return;

    if (_god.On)    st.incomingDamageMult = 0f;
    if (_damage.On) { st.damageMult = _damageMult; st.critChance = 1f; }
    if (_speed.On)  st.moveSpeedMult = _speedMult;
    if (_jumps.On)  st.extraJumps = 99;
    // ...
}

Обратите внимание на else if (GameHealth.GodMode) GameHealth.GodMode = false; — выключенный тумблер обязан не просто «перестать писать», а вернуть игре её значение. Иначе чит выключается только до конца забега.

В Valheim та же логика выглядит иначе: там основную работу делает Harmony-префикс, который вообще не даёт игре списать ресурс, а покадровый Enforce — это подстраховка для источников, которые пишут значение мимо патча (утопление, Character.UseHealth):

public static void Enforce(Player player)
{
    if (player == null || player.IsDead())
        return;   // труп доливать не надо: рэгдолл и экран респавна — дело игры

    if (_infiniteStamina)
    {
        float max = player.GetMaxStamina();
        if (player.GetStamina() < max)
            player.AddStamina(max);
    }
    // ...
}
5. Интероп стоит денег (IL2CPP)

Автоубийство врагов — обход реестра EnemyRegistry.All с вызовом TakeDamage на всём живом. Первая версия делала это каждый кадр. На 240 FPS это 240 проходов по списку в секунду, и каждое обращение к элементу — переход managed → native через интероп.

Просадка была заметна глазом. Починка тривиальная — таймер:

private const float AutoKillInterval = 0.25f;

if (_autoKill.On && Time.realtimeSinceStartup >= _autoKillNext)
{
    _autoKillNext = Time.realtimeSinceStartup + AutoKillInterval;
    _autoKilled += KillEnemies(false);
}

Четверть секунды в игре читается как «мгновенно»: враги умирают практически на спавне. Разница — 4 прохода в секунду вместо 240.

Ещё два момента в том же методе.

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

e.TakeDamage(1000000.0);

Снимок списка перед обходом. EnemyRegistry.All — живая коллекция, и она мутирует прямо во время того, как мы по ней идём и всех убиваем. Сначала копия, потом убийства.

И мелочь, которая сильно меняет ощущения: объекты с флагом IsProp (ящики и прочий декор) автоубийство пропускает. Иначе весь уровень взрывается в момент загрузки — с фейерверком.

6. Не тот хук (Mono)

Дальше — Valheim, и грабли здесь совсем другого сорта.

Пока открыто чит-меню, персонаж не должен ходить, махать оружием и вертеть камерой, а курсор должен быть свободен. Очевидный кандидат на патч — Player.TakeInput(). Имя идеальное. Проблема одна: его в игре никто не вызывает.

Настоящие ворота — PlayerController, и почти всё, что должно замолкать при открытом окне (движение, камера, захват курсора, хотбар, чат, миникарта), спрашивает у игры ровно один вопрос: открыт ли инвентарь.

// Достаточно ответить «да» на один вопрос, и вся игра ведёт себя так,
// будто перед игроком нормальное окно.
Patch(harmony, AccessTools.Method(typeof(InventoryGui), "IsVisible", new Type[0]),
      "InventoryVisiblePrefix", null);

public static bool InventoryVisiblePrefix(ref bool __result)
{
    if (!Blocking) return true;   // отдать управление оригиналу
    __result = true;
    return false;                 // оригинал не вызывать
}

Один патч вместо десятка. Плюс два подстраховочных: PlayerController.TakeInput() пропускает проверку инвентаря, когда активен геймпад, а GameCamera.UpdateMouseCapture() может перезахватить курсор.

Мораль, за которую я заплатил вечером отладки: не патчите метод по имени, проверьте сначала, что его вообще кто-то вызывает. В Mono-игре это тридцать секунд поиска по ссылкам в dnSpy.

Отдельно: любой патч оформлен так, что отсутствие цели — это предупреждение в лог, а не исключение:

if (target == null)
{
    Logger.LogWarning("Patch target not found (" + prefix + ") — эта фича работать не будет");
    return;
}

Игру обновят, одна сигнатура уедет — сломается одна фича, а не весь плагин. Для мода, который живёт дольше одного патча игры, это не опция.

7. Готовый чит игры оказался хуже своего (Mono)

В Valheim есть Player.SetGodMode(). Казалось бы — вот бессмертие, бери и пользуйся.

Две причины не брать. Во-первых, он не блокирует урон, а только не даёт здоровью дойти до нуля: персонаж всё так же отлетает, горит и травится. Во-вторых, при первом же попадании он штампует флаг «читер» в ZDO персонажа — то есть в сетевую запись, которая уезжает на сервер.

Свой вариант — префикс на Character.ApplyDamage():

Patches.Patch(harmony,
    AccessTools.Method(typeof(Character), "ApplyDamage",
        new Type[] { typeof(HitData), typeof(bool), typeof(bool), typeof(HitData.DamageModifier) }),
    typeof(Cheats), "ApplyDamagePrefix", null);

public static bool ApplyDamagePrefix(Character __instance)
{
    return !(_immortal && IsLocal(__instance));
}

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

IsLocal — сравнение по ссылке с Player.m_localPlayer. Без него бессмертными становятся все персонажи в мире, включая мобов, и игра превращается в другой жанр.

Похожая история со скиллами. Skills.GetSkillLevel() для чтения не годится — он округляет значение вниз и добавляет модификаторы от бафов, то есть возвращает не то, что лежит в сейве. Настоящий уровень живёт в Skills.Skill.m_level, а достаётся через приватный Skills.GetSkill() — который заодно создаёт запись для скилла, которым персонаж ни разу не пользовался.

И ещё один сюрприз: у игры есть суммарный кап на скиллы. Выставляешь всё на 100, идёшь играть — а игра тихо опускает остальные обратно. Лечится одной строкой в Update:

if (skills != null && skills.m_useSkillCap)
    skills.m_useSkillCap = false;
8. Публичный API есть, но он не для батча (Mono)

«Открыть всё» — пометить все предметы игры как найденные. Публичный путь — Player.AddKnownItem(). На один предмет это правильный вызов: он ставит флаг и показывает всплывашку «новый предмет».

На пятистах предметах он ставит в очередь пятьсот всплывашек. Игра честно показывает их по очереди, и следующие минуты интерфейс занят только этим.

Сами множества приватные, так что через рефлексию:

private static readonly FieldInfo KnownMaterialField = typeof(Player).GetField(
    "m_knownMaterial", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);

HashSet<string> materials = (HashSet<string>)KnownMaterialField.GetValue(player);
foreach (var prefab in ObjectDB.instance.m_items)
    materials.Add(prefab.GetComponent<ItemDrop>().m_itemData.m_shared.m_name);

Дальше выясняется, что строительных деталей нет ни в m_items, ни в m_recipes. Единственный их список в игре — таблицы m_buildPieces у молота, мотыги и культиватора. То есть чтобы «открыть всё строительство», надо пройтись по предметам, найти те, что несут PieceTable, и собрать детали оттуда.

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

Бонус: где IL2CPP-игра хранит прогресс

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

Go Next! хранит его в Unity PlayerPrefs, а PlayerPrefs на Windows — это реестр:

HKCU\Software\Go Next demo\Go Next demo

Имена значений там не совпадают с ключами. Unity приписывает к ключу хеш: <key>_h<hash>. Хеш — djb2 с XOR по UTF-8 байтам, seed 5381:

function Get-PrefHash {
    param([string] $Key)
    [long] $mask = 4294967295
    [long] $h = 5381
    foreach ($b in [System.Text.Encoding]::UTF8.GetBytes($Key)) {
        $h = ((($h * 33) -band $mask) -bxor $b) -band $mask
    }
    return [uint32] $h
}

Про $mask: 0xFFFFFFFF в PowerShell парсится как int32 = −1, поэтому маску приходится держать long. Полчаса на отрицательные хеши я потратил честно.

Строки лежат как REG_BINARY: UTF-8 плюс завершающий 0x00. Записывать надо именно так, иначе игра значение не прочитает и молча начнёт заново.

Сами списки ID (101 предмет, 44 оружия, 56 пассивок) вытащены из global-metadata.dat — того самого файла метаданных IL2CPP, где лежат все строковые литералы игры. Строки вроде bootlegexcalibur, momsspaghetti, schrodingersbox находятся обычным grep по файлу.

Побочное наблюдение, которое мне понравилось больше самого чита. У предметов и оружия в метаданных есть UnlockItem, IsItemUnlocked и ShowItemLocked. У персонажей — ничего похожего: ни UnlockCharacter, ни IsCharacterUnlocked, ни ShowCharacterLocked. То есть системы блокировки персонажей в демке нет вообще, все четверо доступны с самого начала, и «открывать» там нечего. Отсутствие символа в метаданных — тоже факт о коде, причём иногда более информативный, чем наличие.

Два практических правила для правки PlayerPrefs:

Игра должна быть закрыта. Unity держит prefs в памяти и переписывает их при выходе. Правки на лету будут затёрты. Скрипт просто отказывается работать, если процесс жив.

Бэкап перед каждой правкой. .reg-экспорт с таймстемпом в отдельную папку, плюс -Restore. Стоит три строки, а хеши имён значений таковы, что руками вы это потом не почините.

Про мультиплеер — и почему оба мода про него знают

Здесь я специально осторожничал, и советую делать так же.

Go Next! умеет кооп на Mirror, и в коопе синхронизируются и прогресс, и состояние врагов — CoopGoldSync, CoopSoulSync, CoopEnemySync. То есть «мегаурон» в коопе — это уже не «я развлекаюсь», а «я порчу забег другому». Трейнер каждый кадр проверяет сетевую сессию и гасит все тумблеры:

private bool NetworkActive()
{
    try { return Il2CppMirror.NetworkClient.active || Il2CppMirror.NetworkServer.active; }
    catch { return false; }
}

Плюс к этому -Apply в редакторе сохранений по умолчанию выставляет set_uploadScore = 0 — читерский прогресс не уезжает в глобальный лидерборд Steam.

Valheim — история тоньше. Плагин трогает только Player.m_localPlayer: свой инвентарь, свои скиллы, своё здоровье. Всё это лежит в собственном файле персонажа .fch на своём диске, по сети не уходит.

Но честных оговорок три:

  • Плагин не привязан к миру. Он работает и на чужом сервере.

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

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

Отдельная причина не использовать SetGodMode() — ровно та же: он оставляет в ZDO отметку, которую видно не только вам.

Сборка: две крайности

IL2CPP-мод ссылается на сборки, которых до первого запуска игры не существует:

dotnet build src/GoNextTrainer -c Release -p:GameDir="D:\SteamLibrary\steamapps\common\Go Next! Demo"

Ссылки идут на MelonLoader\Il2CppAssemblies — то, что сгенерировал загрузчик. Таргет — net6.0, потому что MelonLoader запускает игру именно на нём. Это не выбор, это ограничение.

Mono-мод собирается компилятором, который уже лежит в Windows:

%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\csc.exe

Никакого SDK, никакого NuGet, build.cmd на двадцать строк, ссылки прямо в valheim_Data\Managed. Цена — этот компилятор умеет только C# 5. Отсюда в коде плагина ни интерполяции строк, ни nameof, ни ?.:

Logger.LogInfo(string.Format("{0} {1} loaded. Menu key: {2}",
    PluginName, PluginVersion, CfgMenuKey.Value));

Выглядит как 2012-й, собирается на любой машине с Windows без единой установки. Для мода, который люди скачивают и пересобирают под свою версию игры, это оказалось правильным компромиссом.

Что я вынес

IL2CPP — не «то же самое, только сложнее». Это другая модель: вы работаете не с кодом игры, а с автоматически сгенерированными обёртками над ним. Всё, что генерируется, может сгенерироваться неправильно (пункт 1) или не сгенерироваться вовсе (пункт 2). Проверять надо не свой мод, а прослойку.

Всё, что игра стрипает, для вас не существует. GUILayout — самый частый случай, но правило общее: если игра чем-то не пользуется, в билде этого нет.

Ищите единственную точку, через которую всё проходит. Character.ApplyDamage вместо трёх методов урона. InventoryGui.IsVisible вместо десяти мест, где надо глушить ввод. Обычно такая точка есть, и она ниже, чем кажется.

Не доверяйте именам. Player.TakeInput() не вызывается. SetGodMode() не даёт god mode. GetSkillLevel() возвращает не уровень скилла. Три попадания подряд в одной игре.

Публичный API рассчитан на сценарий игры, а не на ваш. AddKnownItem для одного предмета правильный, для пятисот — нет. Die() убивает, но не роняет лут.

Пишите каждый кадр или не пишите вовсе. Игра пересчитывает свои значения в своём Update. Однократная запись — это не чит, это подмигивание.

Оба проекта лежат на GitHub со всей документацией:

  • NEXTBREAKER — MelonLoader-трейнер, редактор сохранений и скрипт починки CoreModule для Go Next! Demo (Unity 6 / IL2CPP)

  • valheim-progression-cheat — BepInEx-меню для Valheim: предметы, инвентарь, скиллы, бессмертие, полёт

Если у вас есть свои грабли из моддинга Unity — особенно из IL2CPP — расскажите в комментариях. У меня накопилось ощущение, что половина этих проблем известна каждому, кто там жил, и не написана нигде.

Схожие новости

#Наименование новостиТональностьИнформативностьДата публикации
1Ошибки и подозрительные места в исходниках .NET 10012.6604-06-2026
2Ты не найдёшь эту ошибку. Потому что её нет в твоём коде. Как Self-describing API спасает от чужих рефакторингов5807-07-2026
3Coachlight.Avalonia: почему в desktop-разработке до сих пор нет нормального онбординга — и как я решил это исправить08.5713-07-2026
4Инди-геймдев как образ жизни: честный разговор о цене мечты011.8214-07-2026
5Ленивая загрузка — не лекарство: почему ваш main.js всё равно весит 2 МБ09.2223-07-2026
6ИграКОД: Генератор судоку на TypeScript: почему найти решение проще, чем доказать единственность-117.7801-08-2026
7Оптимизация без AI: как я автоматизировал API-ручки и типы-2529-06-2026
8Свободные функции вместо методов, но с полиморфизмом. Что это даёт на самом деле?07.5624-07-2026
9In-memory база врёт: 5 расхождений с продовой БД0707-07-2026
10Моддер добавил трассировку пути в GTA 2, написав прослойку для Direct3D 9014.6326-09-2026

Классификация: Мнения. Схожих патентов: 0. Схожих новостей: 10. Тональность: 1. Информативность: 10.76. Источник: habr.com.