Однажды на собеседовании меня попросили привести примеры написанного мной кода: один — которым я наиболее горжусь, и другой — который я считаю наиболее неудачным. Не долго думая, я осознал, что на оба вопроса у меня готов ответ, и это один и тот же фрагмент кода. Горжусь я им потому, что это, пожалуй, из всей моей практики именно этот код оказался наиболее весом, а стыжусь из-за него, так как по мнению большинства из его читателей этот код начался с костыля, который затем стали развивать: 

/// <summary>
/// Переадресует все вызовы от метода 'from' к методу 'to'.
/// </summary>
public static void RedirectCalls(MethodInfo from, MethodInfo to)
{
    // GetFunctionPointer обеспечивает обязательную компиляцию этого метода.
    var fptr1 = from.MethodHandle.GetFunctionPointer();
    var fptr2 = to.MethodHandle.GetFunctionPointer();
    PatchJumpTo(fptr1, fptr2);
}

/// <summary>
/// Примитивный патч. Вставляет переход от 'target' к 'site'. Срабатывает даже в том 
/// случае, если вызыватели обоих методов уже были скомпилированы 
/// </summary>
private static void PatchJumpTo(IntPtr site, IntPtr target)
{
    // R11 является volatile.
    unsafe
    {
        byte* sitePtr = (byte*)site.ToPointer();
        *sitePtr = 0x49; // mov r11, target
        *(sitePtr + 1) = 0xBB;
        *((ulong*)(sitePtr + 2)) = (ulong)target.ToInt64();
        *(sitePtr + 10) = 0x41; // jmp r11
        *(sitePtr + 11) = 0xFF;
        *(sitePtr + 12) = 0xE3;
          }
    
    /*
        Обратите внимание: в версии x86/32 бит модно отбросить префиксы REX (0x49, 0x41) для кодов операций.
        Также потребуется поменять ulong на uint. Так мы получим коды операций для
        mov ebx, target
        jmp ebx
        (получается, что это просто работает, поскольку префикс REX превращает ebx в R11).
    */
}

Это код на C#, записывающий некоторый машинный код для x86/64, чтобы изменить поведение программы. Для чего? Читайте, обо всём расскажу.

В 2015 году Colossal Order и Paradox выпустили Cities: Skylines (CS) — игру-шедевр, написанную на Unity и в целом C#. Я имею некоторый опыт в моддинге видеоигр. Обычно мне хватает позаниматься игрой несколько часов — и я уже хочу не столько в неё играть, сколько начинаю разбираться в ей технических аспектах. В CS вы можете загружать в игру ваши собственные сборки на .NET. Также в игре предлагается упрощённый интерфейс, через который удобно менять её элементы, например, менять конфигурацию индустриальных зон и т.д. Механизм сделан с умом, но функционально весьма ограничен. Меня никогда не устраивало, если можно просто скорректировать несколько чисел или обменять игровые ресурсы одни на другие — ведь моддинг становится по-настоящему интересным, когда появляется возможность поэкспериментировать с игровой механикой.

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

На данном этапе я потратил целый уикенд на исследование того, какими способами можно переадресовывать вызовы методов в .NET, поскольку в таком случае я бы получил возможность менять в игре любой код на мой выбор и подрубаться к нему именно в той части, которую мне интересно модифицировать.

Ранее, занимаясь моддингом Company of Heroes (CoH) и Dawn of War (DoW) от компании Relic Entertainment, я много работал с инъекциями DLL и пропатчивал игровой код. До сих пор люблю вспоминать, как написал патч на ассемблере, позволявший загружать среду выполнения .NET в DoW2. Затем этот патч делал новые привязки, запуская их прямо в систему игровых скриптов (игра написана на Lua), а далее с их помощью обменивался информацией с сервером. Да, у меня было счастливое детство, и, вероятно, вдоволь времени на подобные занятия. Что касается CS, где используется Mono – свободно распространяемая реализация .NET — я надеялся найти «верный» способ, которым можно было бы без труда переадресовывать вызовы методов. Выработал  несколько подходов, которые более-менее работали. Простейший из них, к сожалению (?) оказался наименее интересным: заставляем  JIT-компилятор собрать метод, а затем просто пропатчиваем машинный код так, чтобы в нём появился переход к целевому методу.  

Сначала я старался так не делать, ведь когда речь заходит о JIT-компиляторах, обычно перед глазами встаёт такая исключительно динамическая среда с героическими оптимизациями, а в процессе выполнения программы оптимизированный код меняется всё сильнее. Мой костыль эту систему сразу бы ломал. Ломал бы полностью и встроенные функции. К моему удивлению, с Mono ни одна из этих проблем не возникла, но целиком исключить их я не мог, поэтому я первым делом обозначил этот проект как «проверку концепции» (proof of concept). Мой костыль может легко сломаться при переходе с Windows на Linux, либо при переходе с одной версии Mono на другую, либо от Mono к какому-нибудь другому динамическому компилятору. Это даже не слишком сложно, любой бы с этим справился. По всей вероятности, никто не сделал этого до меня, так как, теоретически, такой подход работать не должен. Но на практике он работает.

С тех пор, как я выпустил этот код, он зажил собственной жизнью. Сначала моддеры CS пытались его копировать досимвольно, а затем принялись обставлять его абстракциями. Код быстро прижился и во многих других сообществах моддеров, в частности, среди тех, кто занимается Unity и другими играми, опирающимися на Mono. С тех пор, когда требуется перенаправить метод в .NET, часто рекомендуют использовать именно этот примитивный патчинг.

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

Мне понадобилось некоторое время, чтобы понять, что произошло. Как-то раз меня попросили сослаться на библиотеку Harmony, по ходу отметив, что эта библиотека должна как-то заместить изобретённый мной подход, поскольку возникает множество проблем, если множество клиентов одновременно пытаются переадресовать один и тот же метод. Кстати, это очень полезная библиотека, и в ней делаются попытки решать сложные проблемы, к которым я даже не подступался (встраивание, множественные переадресации методов, работа с динамическими методами, т.д.). По сути своей Harmony всё так же пропатчивает скомпилированную функцию (но при переходе использует RAX, а не R11).

Оглядываясь назад, думаю, что это был хороший урок. Код решает конкретную проблему в своей зоне ответственности, хотя, с точки зрения любой устоявшейся программной инженерии, он и не должен работать. Это полезно иметь в виду таким как я, кто обычно высокого мнения о хорошей теории. Должен признать, что, пусть моя находка и неоднозначным образом повлияла на обширную базу кода в целом, пожалуй, именно этот код оказался наиболее влиятельным из всего, что я по сей день успел написать. Вероятно, им успели воспользоваться на нескольких сотнях тысяч машин, и, благодаря нему, мир действительно стал лучше, поскольку этот код помог многим и многим людям проявить свои творческие умения за моддингом любимых игр. Миссия выполнена.

Комментарии (10)


  1. vadimr
    30.06.2025 15:30

    Героическая борьба с ранним связыванием и влиятельная реализация оператора присваивания from=to.

    По-настоящему здесь интересен другой вопрос: чем руководствовались программисты Microsoft, делая раннее связывание в C#? Ведь к тому времени уже был Objective C с поздним связыванием методов.

    Обычно принято рассматривать раннее связывание как костыль для эффективности скомпилированного кода. Но к .Net это вроде бы как не должно иметь отношения.


    1. mayorovp
      30.06.2025 15:30

      Причём тут раннее и позднее связывание? Проблема-то тут не в связывании, а в том что один программист забыл предусмотреть точку расширения в коде, а другому слишком нужно в неё влезть.


      1. vadimr
        30.06.2025 15:30

        Так при позднем связывании не нужно было бы заранее предусматривать точки расширения. Просто присвоить метод.

        Исходя из здравого смысла, программа должна бы иметь возможность расширяться в любом месте.


        1. Siemargl
          30.06.2025 15:30

          Речь про правки уже скомпилированного кода.

          Интересно, получается что в NET платформе не защищен сегмент кода?!


          1. vadimr
            30.06.2025 15:30

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


            1. Siemargl
              30.06.2025 15:30

              Так весь смысл статьи, что мод не сделан в виде DLL с нужными точками входа


              1. vadimr
                30.06.2025 15:30

                Если бы программа была на языке с поздним связыванием (как, например, Objective C вместо C#), то не нужно было бы для этого делать DLL и предусматривать точки входа.

                На хабре эта тема разбиралась, например, тут. Автор сделал хак, не имея возможности в силу ограничений языка использовать честный интроспективный method swizzling.


                1. Siemargl
                  30.06.2025 15:30

                  Если бы да кабы, но на таких языках игры почему-то редко пишут


                  1. vadimr
                    30.06.2025 15:30

                    Не так уж чтобы совсем редко - вся iOS так работает. Но в целом да, такие техники выше среднерыночного уровня программирования. Вот автор статьи даже считает это своим величайшим достижением.


  1. aamonster
    30.06.2025 15:30

    Чувствую какой-то обман во всём этом. Ещё лет 20 назад для выполнения такого кода нужно было выполнять крэкс-пэкс-фэкс, чтобы получить права на запись в память (доводилось тогда использовать подобный патчинг... Не горжусь этим, но на тот момент проблему решили – уже даже не помню, какую именно), а потом отказаться от них.