[Гайд][Легко][1.6+] Модификация чужого кода при запуске (трансфомеры)

808
3
127
О чем речь?
В последних версиях Minecraft'a разработчики всеми способами борются против того, чтобы моддеры изменяли классы в minecraft.jar. Для этого была выстроена огромная система, с помощью которой можно изменять (трансформировать) классы во время запуска игры. Однако, чтобы этим пользоваться, нужны некоторые познания в устройстве JVM и её байткода, а так же умение использовать библиотеку ASM.
Я написал небольшой мод, с помощью которого возможно вставлять хуки (вызовы своих статических методов) в код майнкрафта, форджа и других модов, не обременяя себя лишними познаниями. Если вы встроите его в сборку своего мода, она вырастет меньше, чем на 100 КБ.


Что можно сделать с помощью таких хуков?
Практически всё. Если немного пораскинуть мозгами, то 95% требующихся изменений вполне реально сделать вставкой хука (личная статистика, а не число из головы!) без особых костылей, а оставшиеся 5% - заменив с помощью хука всё содержимое метода.
Примеры конструкций, которые можно вставить через этот мод:
Код:
public void foo(String someParameter){
    MyHooksClass.onPreFoo(this, someParameter); // хук
    bar(); // тело метода
    MyHooksClass.onPostFoo(this, someParameter); // хук
}
Код:
public void foo(String someParameter){
    if (MyHooksClass.onPreFoo(someParameter)) return; // хук
    bar(); // тело метода
}
Код:
public String foo(String someParameter){
    String local = bar(); // тело метода
    return MyHooksClass.foo(local); // хук, передается значение, которое изначально шло в return
    // return local; <-- оригинальный код, никогда не вызывается
}
Код:
public int getSomeInt(){
    return 0;
    // return someInt <-- оригинальный код, никогда не вызывается
}

Короче говоря, вставить можно что угодно. Главное ограничение - код можно вставить только в начало или конец метода (точнее, в любую точку выхода, в том числе через return). Ну и в качестве костыля есть вставка по номеру строки, но я крайне не рекомендую ей пользоваться: она может сломаться от чего угодно.


Где мне взять этот мод и как его подключить?
GloomyFolken/HookLib
Чтобы подключить кормод в IDE, нужно закинуть в src сорцы с гитхаба, а потом открыть настройки запуска:
В IntelliJ IDEA: Run -> Edit configurations
В Eclipse: Run -> Run (Debug) configurations
И в VM arguments написать:
Код:
-Dfml.coreMods.load=gloomyfolken.hooklib.example.ExampleHookLoader
Указываются IFMLLoadingPlugin'ы через запятую. Замените название из примера на название своего HookLoader'a. О том, как его создать, читайте ниже.
Работает это на всех версиях майна, начиная с 1.6. Если вы используете 1.8+, замените импорты во всем пакете gloomyfolken.hooklib.minecraft: cpw.mods.fml на net.minecraftforge.fml.



Как, собственно, вставлять хуки?
Не так быстро. Сначала нужно сделать кормод (мод, который запускается до майнкрафта). "Главный" класс кормода - это класс, реализующий интерфейс IFMLLoadingPlugin. На деле большая часть возможностей кормодов не понадобится, нам всего лишь нужно выполнить свой код до запуска майна. Поэтому удобно наследовать мою базовую реализацию этого класса. Всё, что нужно сделать - зарегистрировать класс с хуками.

Код:
public class ExampleHookLoader extends HookLoader {

// не пишем в этом классе ВООБЩЕ ничего кроме того что есть в примере.
// Если вы случайно здесь загрузите какой-то класс майнкрафта, то все сломается.

    // включает саму HookLib'у. Делать это можно только в одном из HookLoader'ов. 
    // При желании, можно включить gloomyfolken.hooklib.minecraft.HookLibPlugin и не указывать здесь это вовсе.
    @Override
    public String[] getASMTransformerClass() {
        return new String[]{PrimaryClassTransformer.class.getName()};
    }

    @Override
    public void registerHooks() {
        //регистрируем класс, где есть методы с аннотацией @Hook
        //обязательно нужно оставить название класса в виде строки, AnnotationHooks.class все сломает
        registerHookContainer("gloomyfolken.hooklib.example.AnnotationHooks");
    }
}


Итак, наконец-то можно приступать к написанию хуков. Вставляются они установкой аннотации @Hook над методом. Возьмем самый простой пример: мы хотим выводить размер окна при каждом ресайзе. Для этого достаточно такого кода:
Код:
@Hook
@SideOnly(Side.CLIENT)
public static void resize(Minecraft mc, int x, int y){
    System.out.println("Resize, x=" + x + ", y=" + y);
}
Разберём подробнее.
  • Модификаторы public и static обязательны для хук-метода, это должно быть очевидно.
  • Если у аннотации @Hook не указано дополнительных элементов, то все параметры установлены по умолчанию. Это значит, что, во-первых, хук вставляется в начало метода, а, во-вторых, после вызова хука выполнение продолжается.
  • Название целевого метода по умолчанию определяется названием хук-метода (в нашем случае - resize). Его можно задать отдельно, указав в аннотации элемент targetMethod = "targetMethodName".
  • Этот же элемент аннотации нужно использовать, чтобы вставить хук в конструктор или инициализацию класса. Для вставки в конструктор нужно указать targetMethod = "<init>", в инициализацию класса = targetMethod = "<clinit>".
  • Класс, куда вставлять хук, определяется первым параметром хук-метода (в нашем случае - Minecraft).
  • Список параметров целевого метода определяется последующими параметрами хук-метода (в нашем случае - int, int).
  • Первым аргументом хук-метод получает this (или null, если целевой метод статический).
  • @SideOnly(Side.CLIENT) указан, чтобы этот метод не вызвал ошибку загрузки класса на сервере. Это нужно писать для всех хуков, которые вставляются в классы в пакете net.minecraft.client.
Другой пример: уменьшаем вдвое показатель брони игрока.
Код:
@Hook(injectOnExit = true, returnCondition = ReturnCondition.ALWAYS)
public static int getTotalArmorValue(ForgeHooks fh, EntityPlayer player, @ReturnValue int returnValue) {
    return returnValue/2;
}
  • injectOnExit = true значит, что хук вставляется на выходе из метода
  • returnCondition = ReturnCondition.ALWAYS значит, что после вызова хук-метода сразу же вызывается return.
  • Благодаря аннотации @ReturnValue перехватывается значение, которое изначально шло в return, и передается хук-методу.
  • Целевой метод в итоге вернёт то, что вернул хук-метод, то есть уменьшенный вдвое показатель брони. Это можно изменить с помощью некоторых элементов аннотации @Hook.
Рассмотрим пример с изменением того, что вернёт целевой метод. Попробуем запретить телепортироваться в ад чаще, чем раз в пять секунд (100 тиков). Однако, телепортация обратно остаётся со стандартной задержкой в 10 тиков.
Код:
@Hook(returnCondition = ReturnCondition.ON_TRUE, intReturnConstant = 100)
public static boolean getPortalCooldown(EntityPlayer player) {
    return player.dimension == 0;
}
  • returnCondition = ReturnCondition.ON_TRUE значит, что после вызова хук-метода вызывается return только если хук-метод вернул true.
  • intReturnConstant = 100 задаёт значение, которое вернет целевой метод. Объект нельзя сделать элементом аннотации, поэтому есть аналогичные элементы для остальных примитивов и для String.
  • Сам хук-метод проверяет измерение, в котором находится игрок, чтобы хук влиял только когда игрок в основном мире.
Естественно, использовать константы получается не всегда. На этот случай можно вернуть значение, которое вернёт другой метод. Сделаем, чтобы яркость всех сущностей, высота которых больше полутора метров, уменьшалась вдвое.
Код:
@Hook(injectOnExit = true, returnCondition = ReturnCondition.ON_TRUE, returnAnotherMethod = "getBrightness")
public static boolean getBrightnessForRender(Entity entity, float f, @ReturnValue int oldValue) {
    return entity.height > 1.5f;
}

public static int getBrightness(Entity entity, float f, int oldValue) {
    int j = ((oldValue >> 20)&15)/2;
    int k = ((oldValue >> 4)&15)/2;
    return j << 20 | k << 4;
}
  • Хук вставляется на выходе из метода и вызывает return только когда хук-метод возвращает true, это уже проходили.
  • returnAnotherMethod = "getBrightness" значит, что при вызове return после вызова хук-метода будет возвращено значение, которое вернет метод getBrightness в этом же классе и с этими же параметрами.
  • Стоит отметить, что если бы мы не перехватывали возвращаемое значение (не добавляли @ReturnValue int oldValue), то и в метод getBrightness не надо было бы добавлять третий параметр.
Иногда возникает необходимость не просто модифицировать какой-то метод, а создать новый, чтобы переопределить метод из суперкласса. Например, мы хотим, чтобы из блока земли выпадал блок травы. Для этого в классе BlockDirt нужно создать метод idDropped:
Код:
@Hook(createMethod = true, returnCondition = ALWAYS)
public static int idDropped(BlockDirt blockDirt, int meta, Random rng, int fortune) {
   return 2;
}
  • createMethod = true значит, что при провалившейся попытке найти целевой метод для вставки хука создается новый метод.
  • Список аргументов создаваемого метода берется из аргументов хук-метода.
  • Возвращаемый тип создаваемого метода можно задать через targetMethodReturnType.
  • Если targetMethodReturnType не указан, то возвращаемый тип берется из возвращаемого типа хук-метода.

Я научился вставлять хуки, но нужный мне метод/поле - private или protected. Что делать?
Просто измените модификатор доступа в сорцах - фордж объявляет всё public при запуске. Это абсолютно безопасно.
За этот мини-гайд спасибо Dahaka.
Инструкция по использованию Access Transformers (>= 1.7.10)

1. Создать папку src/main/resourses/META-INF.
2. Создать в ней файл <modid>_at.cfg.
3. Заполнить файл:
Код:
# Это комментарий

# Все поля в классе Item будут публичными
public net.minecraft.item.Item *

# Все методы в классе Item будут публичными (опасно, может не собраться из-за того, что в производных классах эти методы будут приватными)
public net.minecraft.item.Item *()

#
# Следущие скопипащены из TinkersConstruct
# Если указывать поля и методы по одиночке, то процесс сборки не будет столь долгим, но это не точно
#

# Какое-то конкретное поле
public net.minecraft.item.ItemPickaxe field_150915_c

# Какой-то конкретный метод
public net.minecraft.entity.monster.EntitySlime func_70799_a(I)V

# Какой-то конкретный метод
protected net.minecraft.entity.item.EntityItemFrame func_110131_b(Lnet/minecraft/item/ItemStack;)V
4. Добавить в build.gradle
Код:
jar {
  manifest {
      attributes 'FMLAT': '<modid>_at.cfg'
  }
}
Если 1.10.2, то еще и это:
Код:
artifacts {
   archives deobfJar
}
5. Пересобрать проект.
Код:
gradlew clean setupDecompWorkspace --refresh-dependencies

Какие ещё есть фичи?
  • (новое) Обязательные хуки. Если добавить в аннотацию isMandatory = true, то при провале попытки вставки хука крашнется игра (иначе просто ругается в лог).
  • Приоритет хука. Можно добавить элемент аннотации priority = HookPriority.HIGH, например. Хуки с более высоким приоритетом вызываются раньше.
  • Явно заданный тип, возвращаемый целевым методом. С точки зрения JVM могут быть методы, которые отличаются только возращаемым типом. На практике компилятор таких методов не генерируют, но в некоторых случаях они могут встретиться (например, это можно сделать при обфускации через ProGuard). На этот случай есть элемент аннотации returnType. P.S: упс, я забыл добавить к нему поддержку примитивов и массивов.
  • Вставка по номеру строки. Можно написать что-нибудь вроде injectOnLine = 1337, и хук будет вставлен в начало строки с номером 1337. На строке для этого должна быть какая-нибудь инструкция (не пустая строка / комментарий / скобка / етц). Главная проблема в том, что в MCP и в Minecraft'e номера строк разные. Для этого можно завести static final boolean MCP = true, при сборке мода заменять её на false, и писать injectOnLine = (MCP ? 1337 : 1488).
  • Возврат null. Кроме примитивной константы и строки можно вернуть ещё null, делается это через returnNull = true.
  • Перехват локальной переменной. Кроме аннотации @ReturnValue есть аналогичная @LocalVariable(номер_переменной). Названия переменных в коде могут не сохраняться, так что вот так вот. Чтобы узнать номер, рекомендую использовать методы в классе VariableIdHelper.
  • Это не моя фича, но можно посмотреть, какой код сгенерируется после применения хука. Поможет в этом опция, которая сохраняет трансформированные классы на диск. Чтобы активировать её, в VM arguments пропишите
Код:
-Dlegacy.debugClassLoading=true -Dlegacy.debugClassLoadingSave=true
  • Регистрация хука вручную, без аннотации. Удобно использовать из вашего HookLoader'а, делается это как-то так:
Код:
HookLoader.registerHook(AsmHook.newBuilder()
        .setTargetClass("net.minecraft.client.renderer.texture.TextureManager")
        .setTargetMethod("bindTexture")
        .addTargetMethodParameters("net.minecraft.util.ResourceLocation")
        .setHookClass(HOOKS_CLASS)
        .setHookMethod("bindTexture")
        .addHookMethodParameter("net.minecraft.util.ResourceLocation", 1)
        .setReturnCondition(ReturnCondition.ALWAYS)
        .build());

Как запаковать получившийся кормод в .jar?
Практически также, как обычный мод, нужно лишь добавить в кормод два файла:
1) META-INF/MANIFEST.MF с таким содержимым:
Код:
Manifest-Version: 1.0
FMLCorePlugin: name.of.YourHookLoader
FMLCorePluginContainsFMLMod: true
Created-By: 1.7.0 (Oracle Corporation)
2) methods.bin (в корне архива с модом). Это файл, который содержит обфусцированные названия методов. Копируете из src/gloomyfolken/hooklib/helper файл methods{xx}.bin (в зависимости от нужной вам версии майнкрафта), переименовываете и кладете в архив с модом. А в идеале - сгенерируйте methods.bin для нужной вам версии MCP самостоятельно. Для этого в сорцах лежит скриптик DictionaryGenerator. Без этого могут возникнуть внезапные ошибки уровня "can not find target method of hook" из-за того, что я генерировал для другой версии MCP.

Саму хуклибу придется паковать в ваш мод, так как в фордже нет механизма зависимостей для coremod'ов. Из-за этого убедительно прошу сменить название пакета gloomyfolken.hooklib, если вы собираетесь выкладывать мод в паблик, чтобы избежать конфиликтов между разными версиями хуклибы.
1) Чтобы в MANIFEST.MF появились нужные вам строки, добавьте в build.gradle следующий код:
Код:
jar {
  manifest {
      attributes 'FMLCorePlugin': 'name.of.YourHookLoader'
      attributes 'FMLCorePluginContainsFMLMod': 'true'
  }
}
2) Положите methods.bin в папку src/main/resources.

Всё сломалось. Почему?
1. За порядком загрузки классов всё же придется следить. Если вы загрузите ваш класс с хуками раньше, чем начнет грузиться майнкрафт, то вызовете этим загрузку классов майна в неподходящий момент и всё сломается.
1.1. Не надо пытаться объединить HookLoader и класс с хуками. HookLoader грузится до начала загрузки майна, контейнер с хуками - после!
1.2 Не надо пытаться написать что-то вроде registerHookContainer(MyHooks.class.getName()). Это тоже вызовет загрузку класса с хуками раньше чем надо.
2. Если все хуки работают в IDE, но ни один - в собранном моде, то, скорее всего, вы не положили нужный methods.bin куда надо.
3. Если все хуки работают в IDE, но какой-то один ломается в собранном моде, то, скорее всего, вам нужно самостоятельно сгенерировать methods.bin для вашей версии MCP. Как это сделать читайте выше.
4. Если на клиенте все работает, а при попытке запустить на сервере вылетает ошибка загрузки какого-то клиентского класса (например, EntityClientPlayerMP), то проблема в том, что вы не прописали у хук-метода @SideOnly(Side.CLIENT). Это обязательно нужно делать если вы вставляете хуки в классы, которые лежат в net.minecraft.client. Дополнительно было бы хорошей идеей вынести все такие хуки в отдельный класс и вызывать для него registerHookContainer() только если выполняется условие FMLLaunchHandler.side().isClient().
5. Если вы осилили весь этот текст, немножко подумали, но хуки все равно не работают - не бойтесь спросить в этой теме.

P.S: текущая версия библиотеки тщательно тестировалась только с Minecraft 1.6.4, могут быть проблемы с более новыми версиями игры. Про все возникающие проблемы пишите здесь или на гитхабе.
 
Последнее редактирование:
808
3
127
"Тем же Javassist'ом" это является в той же мере, что "тем же ASM'ом". Та часть javassist'a, через которую можно ковырять байткод от ASM'а принципиально ничем не отличается, а при использовании рантайм-компиляции с немалой вероятностью навернётся сервер из-за того, что клиентские классы там не грузятся. У меня:
  • не нужно ковырять байткод
  • нет опасностей рантайм-компиляции
  • нет заметного увеличения скорости запуска Скомпилировать хоть что-то за 100 мс - это довольно быстро. В моем проекте в районе 50 хуков.
  • не нужно париться по поводу обфусцированных названий классов - они автоматически обфусцируются с помощью форджевского ремаппера
 
1,239
2
24
Глуми,просто круто,мне больше не придется ломать голову и пилить что то с помощью ASM.
Кстати,можешь сделать на 1.7.10?Или если я вставлю код как часть своего мода будет работать?
P.S ссылки на маппинги битые.Смотреть так: 1.6.4 , 1.7.10 , 1.8
 

CumingSoon

Местный стендапер
1,634
12
269
Круто придумал, 10 из 10.Давно искал что то подобное.
Кстати,если сорцы под 1.7.10 компильнуть будет работать?Я сейчас на работе и потестить не смогу
 
471
5
Хук с левой
Хук с правой
Апперкот !
=D
 
503
3
До этого момента я юзал джавассист(проста,даже очень). Были проблемы с заменой(4%). Думал мол "зачем лишняя библиотека,которая работает "через раз"". Закачал сие чудо и удивился. Не надо лишних либ,простое управление и работает. Да,была проблема,поэтому я в твой плагин в injectData вставил код. А теперь вопрос:могу ли я заменить метод или класс?
 
808
3
127
Полностью заменить метод нельзя, но можно сделать фактически то же самое, вставив в начало метода хук, который будет делать то же самое с нужными изменениями.
Код:
public String foo(String t1){
    String t2 = bar() + t1;
    doSomeUselessStuff();
    return t2;
}

Если нужно выпилить вызов doSomeUselessStuff(), то можно добавить такой хук:
Код:
AsmHook.newBuilder()
.setTargetClassName("class.SomeClass")
.setTargetMethodName("foo")
.appendTargetMethodParameters(Type.getType(String.class))
.setTargetMethodReturnType(Type.getType(String.class))
.setHooksClassName("hooks.class.Name")
.setHookMethodName("foohook")
.appendHookMethodParameter(TypeHelper.getType("class.SomeClass"), 0) // кстати, для вставки this отдельный метод я сделал, скоро закоммичу
.appendHookMethodParameter(Type.getType(String.class), 1)
.setReturnCondition(ReturnCondition.ALWAYS)
.setReturnValue(ReturnValue.HOOK_RETURN_VALUE)
И такой хук-метод:
Код:
public static String foo(SomeClass obj, String t1){
    return obj.bar() + t1;
}
 
808
3
127
Оно в основном для запуска ивентов и предполагалось, и именно для этого нужна возможность вызова return, если хук-метод вернул true. Всё-таки есть очень много мест, в которые авторы форджа не додумались запихнуть ивент. Имхо лучше бы не делали ивентов вообще, но сделали удобную возможность "вставлять" их самим через интерфейс вроде моего. Это бы положительно сказалось на производительности.

Блин, только что придумал вставку хуков через аннотации над хук-методом. Реализовать, что ли...

Народ, будет ли такая вставка удобнее?
Код:
@Hook(
// целевой класс определяется в зависимости от параметров хук-метода
targetMethod="createExplosion", targetMethodObf="a",
position=HookPosition.ON_ENTER, //будет по умолчанию
returnCondition=ReturnCondition.ON_TRUE,
returnValue=null // может быть примитивной константой, строкой или null, а по умолчанию возвращается то, что возвращает хук-метод (ну или void)
)
public static boolean onCreateExplosion(World instance, Entity entity, double x, double y, double z, float blah, boolean blah){

}
 
167
3
23
А на minecraftforum/minecraftforge/minecraftforge wiki будешь закидывать гайд?
 
2,955
12
Вообще есть офигенный фреймвурк для ASM и майна, называется Mixin, от тех ребят, что сейчас пилят Sponge API. Можно загуглить "SpongePowered Mixin", результатом будет гитхаб. На гитхабе и оффициал тутториалы по нему, и его исходники. Правда все это ещё в разработке, как и сам Sponge API.
 
808
3
127
Чот с первого взгяда кажется, что в байткоде проще разобраться, чем в нём xD
Ну вообще я издеваюсь. Похоже, он действительно очень крут для серьезных изменений (хотя смотреть их код мне лень, а документация сильно не дописана), но добавить что-нибудь вроде вызова события из ванильного метода через мой велосипед явно проще. Забавное совпадение, что тоже они используют аннотации, как и мне недавно взбрело в голову. Видимо, это действительно удобнее.
 
2,955
12
Он крут, да. Очень даже крут.
[merge_posts_bbcode]Добавлено: 25.06.2015 14:54:59[/merge_posts_bbcode]

А разобраться в нем очень советую.
 
1,239
2
24
Что бы русский шрифт был,нужно в eclipse/eclipse.ini в самый конец доавить строку -Dfile.encoding=UTF-8
 
Сверху