Я поставил 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, префиксы/постфиксы |
прямые вызовы через сгенерированные обёртки |
Что видит мод |
настоящие |
обёртки, сгенерированные из |
Сборка мода |
|
.NET SDK 8, |
Размер кода |
~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 — расскажите в комментариях. У меня накопилось ощущение, что половина этих проблем известна каждому, кто там жил, и не написана нигде.