Поставил 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, префиксы/постфиксы | прямые вызовы через сгенерированные обёртки |
Что видит мод | настоящие | обёртки, сгенерированные из |
Сборка мода |
| .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 строк написано нормальными вертикальными и горизонтальными группами.
Игра собрана на новой 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 из конфига. Там это просто работает.
Наивная версия: по нажатию 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 (ящики и прочий декор) автоубийство пропускает. Иначе весь уровень взрывается в момент загрузки — с фейерверком.
Дальше — 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(), тоже приватные. Без этого всё открыто, но в интерфейсе ничего не появилось до следующего открытия верстака.
Трейнер работает внутри забега. Постоянный прогресс — открытые предметы, оружие, пассивки — живёт отдельно, и до него удобнее добраться снаружи, не поднимая игру.
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 10 | 0 | 12.66 | 04-06-2026 |
| 2 | Ты не найдёшь эту ошибку. Потому что её нет в твоём коде. Как Self-describing API спасает от чужих рефакторингов | 5 | 8 | 07-07-2026 |
| 3 | Coachlight.Avalonia: почему в desktop-разработке до сих пор нет нормального онбординга — и как я решил это исправить | 0 | 8.57 | 13-07-2026 |
| 4 | Инди-геймдев как образ жизни: честный разговор о цене мечты | 0 | 11.82 | 14-07-2026 |
| 5 | Ленивая загрузка — не лекарство: почему ваш main.js всё равно весит 2 МБ | 0 | 9.22 | 23-07-2026 |
| 6 | ИграКОД: Генератор судоку на TypeScript: почему найти решение проще, чем доказать единственность | -1 | 17.78 | 01-08-2026 |
| 7 | Оптимизация без AI: как я автоматизировал API-ручки и типы | -2 | 5 | 29-06-2026 |
| 8 | Свободные функции вместо методов, но с полиморфизмом. Что это даёт на самом деле? | 0 | 7.56 | 24-07-2026 |
| 9 | In-memory база врёт: 5 расхождений с продовой БД | 0 | 7 | 07-07-2026 |
| 10 | Моддер добавил трассировку пути в GTA 2, написав прослойку для Direct3D 9 | 0 | 14.63 | 26-09-2026 |