ESPNWhite Sox's Montgomery sets single-season strikeout record with 224RTP DesportoFinalíssima em Pontevedra. Vasco Vilaça procura título mundial de triatloThe Jerusalem PostAuthenticity and Modernity Party wins Morocco vote, Islamists return to oppositionPunchPeter Obi not godfather to Soludo’s children — First Lady’s aideCNN TürkTürkiye’nin en sıcak ve en kurak yılları belli olduInquirerAfrican swine fever on pigs confirmed in Agutaya, PalawanESPN DeportesRussell le gana a Max en emocionante final en GP de AzerbaiyánObservador DesportoQuatro corpos recuperados após explosão em AtenasThe GuardianSlovenia v Scotland: Nations League – liveBBC SportRepublic of Ireland confirm Israel game will go aheadFox NewsKate Upton retires, high school football game rocked by what some say is a cheap shot & iOSU's new kicker?BBC عربيالبيت الأبيض يمنع "سي إن إن" من مرافقة ترامب على متن الطائرة الرئاسية "إير فورس وان"
The Daily Newsstand · Free, Always
Saturday, September 26, 2026

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

Translate

Я поставил 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 — расскажите в комментариях. У меня накопилось ощущение, что половина этих проблем известна каждому, кто там жил, и не написана нигде.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.