Всем привет! Эта статья была написана из-за отсутствия адекватных русскоязычных руководств по моддингу, особенно для новых версий Minecraft. То, что я опишу ниже, будет применимо для таких загрузчиков модов, как Fabric и Forge, а также для реализации различных функций.

Для начала - немного теории. Когда вы пишете любую программу на Java и компилируете её, вы получаете файл с расширением .jar. Если открыть этот файл как архив, то внутри, среди прочего, вы найдёте файлы с расширением .class. Это и есть ваш код, но уже в виде Java байт-кода. Далее эти файлы загружаются в виртуальную машину Java (JVM) и выполняются. Любой загрузчик модов построен на том, что он модифицирует процесс загрузки классов, изменяя байт-код этих самых классов в соответствии с указаниями из модов. (Да, технически я очень упростил).

Однако не всё так просто. Зачастую код коммерческих приложений, включая Minecraft, проходит через обфускацию - процесс, при котором оригинальные названия классов, полей и методов заменяются на бессмысленные символы (например, a, b, aa). Это затрудняет обратную разработку. С Minecraft такая ситуация была долгое время, хотя в последних версиях официальные маппинги стали значительно лучше(а недавно и вовсе обфускацию отменили).

Разумеется, также понадобится среда разработки, такая как IntelliJ IDEA. Я выберу загрузчик Fabric, а в качестве инструмента для модификации байт-кода - Mixin. Всё это уже собрано в удобный шаблон проекта.

Итак, переходим в официальный репозиторий Fabric Example Mod. В ветке выбираем нужную версию игры (я выбираю 1.20.x).


После того, как скачаем, нам нужно понять какие требования к версии JDK у сборщика gradle, переходим в build.gradle и ищем:

java {
	// Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task
	// if it is present.
	// If you remove this line, sources will not be generated.
	withSourcesJar()

	sourceCompatibility = JavaVersion.VERSION_21
	targetCompatibility = JavaVersion.VERSION_21
}

Видно что требует jdk 21, качаем и ставим.

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

Я уже указал:

loom {
    splitEnvironmentSourceSets()
	mixin {
		defaultRefmapName = "your-mod-id-refmap.json"
		useLegacyMixinAp = true
	}
	mods {
		"modid" {

			sourceSet sourceSets.client
		}
	}

}

тут я предлагаю вам стереть упоминания модуля main, в modid ибо не ясно зачем он. Видимо это что-то из API, но API мы не используем.

Так вот, вам надо вставить:

mixin {
		defaultRefmapName = "your-mod-id-refmap.json"
		useLegacyMixinAp = true
	}

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

Теперь надо скачать IDEA (у меня версия 2022.3.3 когда ещё не добавили ИИ).

Открываем IDEA и импортируем проект, скорей всего получите такое(иногда вообще ничего не будет, вручную перезагрузите gradle проект):

Ошибка из-за того, что IDEA не использует JAVA установленную в систему.
Ошибка из-за того, что IDEA не использует JAVA установленную в систему.

Собственно это происходит потому, что IDEA почему-то даже не пытается искать в системных переменных и выбирает последнюю версию. Поэтому переходим по File - Settings - Build, Execution, Deployment - Gradle - В Пункте Gradle JVM выбираем наш JDK , затем Apply и OK. Затем Project Structure - Project - SDK - выбираем наш JDK, так же Apply и OK, снова перезагружаем gradle проект.

После этого подтянуться зависимости, будет это около 10 минут. Теперь генерируем исходный код. Запускаем CMD переходим в корень проекта и выполняем gradlew genSources.

Теперь исходный код находится в .gradle/loom-cache/minecraftMaven/net/Minecraft и тут две папки клиент и сервер. Да мы при создании мода укажем что меняем, и от этого будем отталкиваться, переходим в клиент и там переходим в архив который перед форматом не содержит source , а там в net.minecraft и всё, код игры.

Теперь не много о структуре проекта. Собственно как и всегда код проекта в src, и у вас будет два модуля это main и client, но ещё можно добавить server, в зависимости от модуля, вам будут доступны соответствующие классы, но почему в main не доступно почти ничего, поэтому я просто советую его удалить(буквально нажать и delete).

В папке src -> client есть java, там не посредственно код а в resources конфиги, собственно для нас будет два главных:

  1. fabric.mod.json - конфиг загрузчика, там на самом деле не много и он на почти не нужен, так как апи мы не будем использовать, а будем использовать перехватчик ClassLoader , то есть миксины.

    fabric.mod.json синтаксис
    1. "schemaVersion": 1
      Назначение: Версия схемы Fabric mod metadata
      Значения: Всегда 1
      Всегда обязателен

    2. "id": "yourmod"
      Назначение: Уникальный идентификатор мода
      Формат: [a-z][a-z0-9_]
      Всегда обязателен

    3. "version": "1.0.0"
      Назначение: Версия мода
      Формат: Semantic Versioning (major.minor.patch)
      Всегда обязателен

    4. "name": "Your Awesome Mod"
      Назначение: Человекочитаемое название мода
      Формат: Любая строка
      Обязательный пункт

    5. description
      Назначение: Описание мода
      Формат: Текст

    6. "authors": [
      "YourName",
      "AnotherDeveloper"
      ]
      Альтернатива: Можно использовать "contributors"
      "contributors": {
      "Developer1": "Главный разработчик",
      "Artist1": "Художник текстур"
      }

    7. "contact": {
      "homepage": "https://yourmod.site", "sources": "https://github.com/yourname/yourmod", "issues": "https://github.com/yourname/yourmod/issues", "discord": "https://discord.gg/yourmod", "email": "support@yourmod.site"
      }

    8. "license": "MIT"

    9. "icon": "assets/yourmod/icon.png"
      Формат: PNG, размер 64x64 или 128x128 пикселей
      Путь: Относительно папки src/main/resources/

    10. "environment": "" назначение: ГДЕ РАБОТАЕТ МОД Значения: "" - везде (клиент + сервер)
      "client" - только клиент
      "server" - только сервер
      "dedicated_server" - только выделенный сервер

    11. "entrypoints": {
      "main": [
      "com.yourname.yourmod.YourMod"
      ],
      "client": [
      "com.yourname.yourmod.client.YourModClient"
      ],
      "server": [
      "com.yourname.yourmod.server.YourModServer"
      ]
      }

      назначение: точки входа. Если не используем API , можно не указывать значения.

    12. "mixins": [
      "yourmod.mixins.json",
      "yourmod.client.mixins.json"
      ]

      Назначение: конфиги с информацией о инъекцией кода(это мы и будем использовать)
      Расширенный вариант:
      "mixins": [
      {
      "config": "yourmod.mixins.json",
      "environment": "*"
      },
      {
      "config": "yourmod.client.mixins.json",
      "environment": "client"
      },
      {
      "config": "yourmod.server.mixins.json",
      "environment": "server"
      }
      ]

    13. "depends": {
      "fabricloader": ">=0.15.0",
      "minecraft": "~1.20.1",
      "java": ">=17",
      "fabric-api": "*"
      }

      Назначение: Зависимости мода
      Синтаксис: "*" - любая версия
      "1.0.0" - точная версия
      ">=1.0.0" - версия или выше
      "<=1.5.0" - версия или ниже
      "1.0.0 - 2.0.0" - диапазон версий
      "~1.0.0" - совместимая версия (1.0.x)

      Пример с зависимостью от других модов:

      "depends": {
      "fabricloader": ">=0.15.0",
      "minecraft": "~1.20.1",
      "java": ">=17",
      "fabric-api": "*",
      "cloth-config": ">=11.0.0",
      "rei": ">=12.0.0"
      }

    14. "suggests": {
      "modmenu": ">=7.0.0",
      "jei": ">=15.0.0",
      "worldedit": "*"
      }

      Назначение: рекомендация модов

    15. "breaks": {
      "old-version-mod": "<2.0.0",
      "conflicting-mod": "*"
      }

      Назначение: Несовместимые моды

    16. "conflicts": {
      "incompatible-mod": "*"
      }

      Назначение: конфликтующие моды

    17. "accessWidener": "yourmod.accesswidener"

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

    Вот пример конфига:

    {
      "schemaVersion": 1,
      "id": "your-mod-id",
      "version": "${version}",
      "name": "Your Mod Name",
      "description": "Describe your mod here!",
      "authors": ["Your Name"],
      "contact": {
        "homepage": "https://your-homepage.com/",
        "sources": "https://github.com/your-username/your-repo"
      },
      "license": "MIT",
      "icon": "assets/your-mod-id/icon.png",
      "mixins": [
        "modid.client.mixins.json"
      ],
      "depends": {
        "fabricloader": ">=0.15.0",
        "minecraft": "~1.20.1",
        "java": ">=17",
        "fabric-api": "*"
      }
    }
  2. Конфиг миксина - Название то, что указали в конфиге.

    mixin.json
    1. required: true
      Назначение: Определяет критичность миксинов для работы мода
      Поведение: Если true - игра аварийно завершится при ошибке применения миксина
      Рекомендация: Всегда true для основных миксинов, false только для опциональных улучшений

    2. minVersion: "0.8.5"
      Назначение: Минимальная версия библиотеки Mixin
      Важно: Должна соответствовать версии Mixin в ваших зависимостях
      Для Minecraft 1.20.1: Рекомендуется 0.8.5 или выше

    3. package: "com.example.bettervillages.mixin"
      Назначение: Корневой пакет для всех классов миксинов
      Структура: Все миксины должны находиться в этом пакете или его подпакетах

    4. compatibilityLevel: "JAVA_17"
      Назначение: Указывает версию Java для компиляции миксинов

    5. refmap: "bettervillages.mixins.refmap.json"
      Назначение: Файл карты ссылок для работы в обфусцированной среде
      Как работает: Хранит соответствия между названиями методов в разработке и в продакшене
      Генерация: Автоматически генерируется при сборке, если настроено в build.gradle
      Важность: Без refmap миксины не будут работать в собранном моде!

    6. mixins - Универсальные миксины:
      Загружаются: На клиенте И на сервере
      Использование: Для классов, существующих в обоих окружениях
      Примеры: World, PlayerEntity, ItemStack

      client - Клиентские миксины:
      Загружаются: Только на клиенте
      Использование: Для GUI, рендеринга, обработки ввода
      Примеры: MinecraftClient, TitleScreen, InGameHud

      server - Серверные миксины:
      Загружаются: Только на сервере
      Использование: Для логики сервера, AI, генерации мира

    7. defaultRequire: 1
      Назначение: Количество обязательных целей для @Inject методов
      Значение 1: Инъекция должна найти хотя бы одну цель (по умолчанию)
      Значение 0: Инъекция опциональна, не вызовет ошибку если цель не найдена

    8. maxShiftBy: 3
      Назначение: Максимальное смещение при использовании @At(shift)
      Безопасность: Предотвращает случайное смещение слишком далеко

    9. allowInjection: true
      Назначение: Разрешить все инъекции
      Безопасность: Можно установить false для отладки

    10. injectionPoints
      Назначение: Список разрешенных точек инъекции
      Стандартные: HEAD, TAIL, RETURN, INVOKE

    11. conformVisibility: true
      Назначение: Приводит видимость перезаписанного метода к оригиналу
      Пример: Если оригинальный метод protected, перезапись тоже будет protected

    12. requireAnnotations: true
      Назначение: Требует явного указания @Overwrite аннотации
      Безопасность: Предотвращает случайные перезаписи методов

    Вот пример:

    {
    	"required": true,
    	"minVersion": "0.8",
    	"package": "com.yourname.yourmod.mixin",
    	"compatibilityLevel": "JAVA_21",
    	"refmap": "client-your-mod-id-refmap.json",
    	"client": [
    		"DeathScreenMixin",
    		"ScreenMixin"
    	],
    	"injectors": {
    		"defaultRequire": 1
    	}
    }

    Собственно в client то, что содержит Mixin, refmap содержит то, что в build.gradle

Теперь то что касается Mixin:

Нам надо определиться какой класс мы хотим изменить. Пусть это будет класс DeathScreen , это GUI которая показывается когда игрок погибает.

Назовём его DeathScreenMixin , теперь что бы указать что мы хотим что-то сделать с DeathScreen, нужно перед сигнатурой класса указать аннотацию @Mixin с одним аргументом, value , которому передаём либо один класс либо через массив указываем набор классов. Вот пример:

package com.yourname.yourmod.mixin;


import net.minecraft.client.gui.screen.DeathScreen;
import org.spongepowered.asm.mixin.Mixin;


@Mixin(value = DeathScreen.class)
public class DeathScreenMixin {

 

}

Ещё если нескольких таких классов, можно добавить второй аргумент - priority и указать значение до 1000. Собственно 0 - макс. приоритет.

Теперь, рассмотрим самое базовое, мы хотим полностью переписать метод, пусть будет render в DeathScreen, находим метод и его сигнатуру:

public void render(DrawContext context, int mouseX, int mouseY, float delta) {

}

Собственно в нашем классе пишем:

@Overwrite
public void render(DrawContext context, int mouseX, int mouseY, float delta) {
return;
}

Самое просто это тут же отменить метод, то есть в теле метода прописать return; Теперь что бы протестить достаточно запустить модуль client, и вот результат:

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

Аннотация Inject
  1. id = "id функции", позволяет по иду менять её аргументы в дальнейшем и так далее.

  2. Аргумент method = "название целевой функции". Так же можно ещё задать как:

    1. method = "render" - Один метод

    2. method = {"render", "tick", "update"} - несколько

    3. method ="addItem(Lnet/minecraft/item/ItemStack;)Z" -Метод с сигнатурой (для перегруженных методов) (о том что это за синтаксис узнаете чуть ниже когда буду объяснять аргументы @At

  3. Аннотация @At

    Аннотация @At
    1. value = "одно из списка":

      1. "HEAD" - Вставить написанную функцию в начало целевой.

      2. "RETURN" - Вставить написанную функцию перед оператором return в целевой

      3. "TAIL" - Вставить написанную функцию в конце(перед последним return)

      4. "INVOKE" - Вставить написанную функцию при вызове метода(дальше будут аргументы для этого) в целевой функции

      5. "INVOKE_ASSIGN" - Вставить написанную функцию после вызова метода с присваиванием(дальше будут аргументы для этого) в целевой функции

      6. "FIELD" - Вставить написанную функцию при доступе к полю(дальше будут аргументы для этого) в целевой функции

      7. "NEW" - Вставить написанную функцию при создании объекта(дальше будут аргументы для этого) в целевой функции.

      8. "CONSTANT" - Вставить написанную функцию при использовании константы(дальше будут аргументы для этого) в целевой функции.

      9. "INVOKE_STRING" - Вставить написанную функцию при вызове метода со строковым аргументом(дальше будут аргументы для этого) в целевой функции.

      10. "JUMP" - Вставить написанную функцию При переходе (условные операторы)(дальше будут аргументы для этого) в целевой функции.

      11. "ARRAY" - Вставить написанную функцию при доступе к массиву (условные операторы)(дальше будут аргументы для этого) в целевой функции.

      12. "INVOKE_ARRAY" - Вставить написанную функцию Доступ к массиву после вызова(дальше будут аргументы для этого) в целевой функции.

    2. target = "значение конкретное в байткоде".
      для метода: "Lполный/путь/к/Классу;имяМетода(ТипыАргументов)ВозвращаемыйТип". Для поля:
      "Lполный/путь/к/Классу;имяПоля:ТипПоля".
      Как указывать данные:
      Примитивные типы:
      Z - boolean
      B - byte
      C - char
      S - short
      I - int
      J - long
      F - float
      D - double
      Ссылочные типы:
      Lполный/путь/к/Классу; - например, Ljava/lang/String;
      Массивы:
      [Тип - например, [I для int[], [[Ljava/lang/String; для String[][]

    3. ordinal - порядковый номер вызова. Если например вызова функции несколько раз, можно указать к какой именно по счёту функции это применить.

    4. slice - ограничение области поиска. slice = @Slice (
      from = @At("HEAD"), // Начало диапазона
      to = @At(value = "INVOKE", target = "...") // Конец диапазона
      ) Думаю тут всё понятно.

    5. opcode - тип байткод-инструкции. Просмотреть их можно через IDEA, view -> show bytecode, пример вызова функции:

      INVOKEVIRTUAL net/minecraft/client/network/ClientPlayerEntity.getScore ()I

      Вот то что в начале, то есть INVOKEVIRTUAL, возможно быть такое, что две функции отличаются только этим, поэтому вам придётся указать опкод, собственно их список:

      1. Opcodes.INVOKEVIRTUAL - Виртуальный вызов метода.

      2. Opcodes.INVOKESTATIC - Статический вызов.

      3. Opcodes.INVOKESPECIAL - Специальный вызов (конструктор, private).

      4. Opcodes.INVOKEINTERFACE - Интерфейсный вызов.

      5. Opcodes.GETFIELD - Чтение поля.

      6. Opcodes.PUTFIELD - Запись поля.

      7. Opcodes.GETSTATIC - Чтение статического поля.

      8. Opcodes.PUTSTATIC - Запись статического поля.

    6. shift - смещение позиции инъекции. Когда функция будет найдена, смещением можно задать направление, и будет заменена не то что бы заменилось, а со смещение на N байткодов. Варианты направления:

      1. Shift.NONE - без смещения.

      2. Shift.BEFORE - перед целевой инструкцией.

      3. Shift.AFTER - после целевой инструкции.

      4. Shift.BY - Смещение на N инструкций. (в отличие от остальных, от места инъекций, after и before смещались после изменения позиции инъекции)

    7. by = 0 - задаёт на сколько инструкций сместиться

  4. cancellable = true или false, возможность досрочно завершить функцию и вернуть значение(да по умолчанию все функции void , но об этом ниже).

  5. locals - захват локальных переменных. Позволяет при написании функции, получить те же аргументы, которые будут когда функцию вызовет игра. По умолчанию захват локальных перемен нет. Собственно присваиваем значение одно из enum-членов, список:

    1. LocalCapture.NO_CAPTURE - нет захвата.

    2. LocalCapture.CAPTURE_FAILHARD - Строгий режим. Выбросит ошибку, если фактические локальные переменные не совпадают с ожидаемыми параметрами метода.

    3. LocalCapture.CAPTURE_FAILSOFT - Мягкий режим. Пропустит захват и выведет предупреждение при несовпадении.

    4. LocalCapture.PRINT - Захватывает переменные и выводит их для отладки.

    1. require = -1 - Требуемое количество успешных инъекций (-1 = не проверять).

    2. expect = 1 - Ожидаемое количество инъекций (выдает предупреждение при несовпадении).

    3. allow = -1 - Максимальное разрешенное количество инъекций (-1 = без ограничений).

Так же важно записав все аргументы функции, в конце указать CallbackInfo ci(если 0 аргументов, то указать его одного), но если у функции есть возвращаемое значение , то CallbackInfoReturnable<ОбъектКоторыйВозвращаетКласс> cir. и что бы отменить функцию вызываем cancel у них, а если вернуть значение, то у cir вызываем setReturnValue и передаём значение, а уже потом отмена функции.

Собственно, вот функция init из DeathScreen которая отвечает за инициализацию логики кнопок:

protected void init() {
        this.ticksSinceDeath = 0;
        this.buttons.clear();
        Text text = this.isHardcore ? Text.translatable("deathScreen.spectate") : Text.translatable("deathScreen.respawn");
        this.buttons.add((ButtonWidget)this.addDrawableChild(ButtonWidget.builder(text, (button) -> {
            this.client.player.requestRespawn();
            button.active = false;
        }).dimensions(this.width / 2 - 100, this.height / 4 + 72, 200, 20).build()));
        this.titleScreenButton = (ButtonWidget)this.addDrawableChild(ButtonWidget.builder(Text.translatable("deathScreen.titleScreen"), (button) -> {
            this.client.getAbuseReportContext().tryShowDraftScreen(this.client, this, this::onTitleScreenButtonClicked, true);
        }).dimensions(this.width / 2 - 100, this.height / 4 + 96, 200, 20).build());
        this.buttons.add(this.titleScreenButton);
        this.setButtonsActive(false);
        this.scoreText = Text.translatable("deathScreen.score.value", new Object[]{Text.literal(Integer.toString(this.client.player.getScore())).formatted(Formatting.YELLOW)});
    }

Тут в целом всё понятно(можете самостоятельно открыть класс, ButtonWidget это класс кнопки интерфейса), нас интересует две строки:

    this.client.player.requestRespawn();
            button.active = false;

А точней одна:

    this.client.player.requestRespawn();

И вот тут важно знать JAVA, потому что даже просмотрев байткод функции, можно не найти упоминания вызова функции requestRespawn, ибо лямбда лежит в том же классе, но отдельно, поэтому нам надо менять не эту функцию, а:

 private synthetic method_19809(Lnet/minecraft/client/gui/widget/ButtonWidget;)V
    // parameter  button
   L0
    LINENUMBER 51 L0
    ALOAD 0
    GETFIELD net/minecraft/client/gui/screen/DeathScreen.client : Lnet/minecraft/client/MinecraftClient;
    GETFIELD net/minecraft/client/MinecraftClient.player : Lnet/minecraft/client/network/ClientPlayerEntity;
    INVOKEVIRTUAL net/minecraft/client/network/ClientPlayerEntity.requestRespawn ()V
   L1
    LINENUMBER 52 L1
    ALOAD 1
    ICONST_0
    PUTFIELD net/minecraft/client/gui/widget/ButtonWidget.active : Z
   L2
    LINENUMBER 54 L2
    RETURN
   L3
    LOCALVARIABLE this Lnet/minecraft/client/gui/screen/DeathScreen; L0 L3 0
    LOCALVARIABLE button Lnet/minecraft/client/gui/widget/ButtonWidget; L0 L3 1
    MAXSTACK = 2
    MAXLOCALS = 2

В нашем классе миксине, мы должны определиться с модификатором доступа функции, возвращаемых тип данных, собственно сохраняем модификатор доступа, то есть private, имя функции тоже, а возвращаемый тип, в самом конце, V, то есть void, список полный есть выше, ну а аргумент это объект класса ButtonWidget, собственно и последнее что надо, понять перед чем надо сделать инъекцию.

Собственно, вот:

INVOKEVIRTUAL net/minecraft/client/network/ClientPlayerEntity.requestRespawn ()V

Собственно target будет содержать:

Lnet/minecraft/client/network/ClientPlayerEntity;requestRespawn()V

В итоге наша функция инъекции(полный пример со всеми аргументами выше) будет выглядеть так:

 @Inject(id = "tutorialInject",
            method = "method_19809",
            at = @At(value = "INVOKE",
                    target = "Lnet/minecraft/client/network/ClientPlayerEntity;requestRespawn()V",
                    ordinal = 1,
                    opcode = Opcodes.INVOKEVIRTUAL,
                    shift = At.Shift.NONE,
                    by = 0),
            cancellable = false,
            locals = LocalCapture.NO_CAPTURE,
            require = -1,
            expect = 1,
            allow = -1)
    private void method_19809(ButtonWidget buttonWidget, CallbackInfo ci){


    }

Разумеется многие значения здесь и так такие по умолчанию и писать каждый раз так не надо, в идеале вот так выглядеть должно было:

 @Inject(id = "tutorialInject",
            method = "method_19809",
            at = @At(value = "INVOKE",
                    target = "Lnet/minecraft/client/network/ClientPlayerEntity;requestRespawn()V",
                    opcode = Opcodes.INVOKEVIRTUAL))
    private void method_19809(ButtonWidget buttonWidget, CallbackInfo ci){


    }

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

Собственно новая аннотация это @Shadow , позволяет получить доступ к полю, к которому изначально доступа нет(оно private и так далее), собственно пишет поле копируя тип и название, и над ним @Shadow, если поле ещё и константа, то между ними @Final. Итоговый класс:

package com.yourname.yourmod.mixin;

import net.minecraft.client.MinecraftClient;
import net.minecraft.client.gui.screen.Screen;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;

@Mixin(value = Screen.class)
public class ScreenMixin {

    @Shadow
    MinecraftClient client;

}

Добавляем этот класс в конфиг миксинов:

{
	"required": true,
	"minVersion": "0.8",
	"package": "com.yourname.yourmod.mixin",
	"compatibilityLevel": "JAVA_21",
	"refmap": "client-your-mod-id-refmap.json",
	"client": [
		"DeathScreenMixin",
		"ScreenMixin"
	],
	"injectors": {
		"defaultRequire": 1
	}
}

Теперь наследуем наш DeathScreenMixin от ScreenMixin. И теперь пишем логику, берём координаты и спавним блоком, вот итоговый класс:

package com.yourname.yourmod.mixin;

import net.minecraft.block.Block;
import net.minecraft.block.BlockState;
import net.minecraft.client.gui.screen.DeathScreen;
import net.minecraft.client.gui.widget.ButtonWidget;
import net.minecraft.client.world.ClientWorld;
import net.minecraft.entity.Entity;
import net.minecraft.util.math.BlockPos;
import net.minecraft.util.math.Vec3d;
import org.objectweb.asm.Opcodes;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;



@Mixin(DeathScreen.class)
public class DeathScreenMixin extends ScreenMixin{



    @Inject(id = "tutorialInject",
            method = "method_19809",
            at = @At(value = "INVOKE",
                    target = "Lnet/minecraft/client/network/ClientPlayerEntity;requestRespawn()V",
                    opcode = Opcodes.INVOKEVIRTUAL))
    private void method_19809(ButtonWidget buttonWidget, CallbackInfo ci){
           World world = this.client.player.getWorld();
        BlockPos coordinateDeathPlayer = this.client.player.getBlockPos();
        coordinateDeathPlayer.add(0,10,0);
        world.setBlockState(coordinateDeathPlayer, Blocks.STONE.getDefaultState());
    }

}

Собственно с этим думаю понятно. Но что, если мы хотим не совместить наш код с частью функцией, а заменить? Для этого есть аннотация @Redirect , по сути почти все те же аргументы, то не добавляем код, а заменяем и разница по аргументам:

  1. Нет параметра id

  2. нет cancellable

  3. нет locals (по умолчанию захватывает)

А так же важно, что в аргументах указывается объект с которым работаем, то есть если вызываем функцию из объекта, в аргументах указываем объект(дальше пример будет)

Собственно изменим наш туториал так, что бы возрождения не было , для этого можно убрать ScreenMixin:

package com.yourname.yourmod.mixin;

import net.minecraft.client.gui.screen.DeathScreen;
import net.minecraft.client.gui.widget.ButtonWidget;
import org.objectweb.asm.Opcodes;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;



@Mixin(DeathScreen.class)
public class DeathScreenMixin {



    @Redirect(method = "method_19809",
            at = @At(value = "INVOKE",
                    target = "Lnet/minecraft/client/network/ClientPlayerEntity;requestRespawn()V",
                    opcode = Opcodes.INVOKEVIRTUAL))
    private void method_19809(ClientPlayerEntity clientPlayerEntity){
        System.out.println("REDIRECT!!!");


    }

}

И вот он результат:

Кнопку нажали и всё. А в консоли - Сообщение:
[10:24:36] [Render thread/INFO] (Minecraft) [STDOUT]: REDIRECT!!!

Собственно осталась небольшая пачка аннотаций, но они не сложные:

  1. Меняем аргументы вызова:

    1. Одного:

      @ModifyArg , вот пример:

      @ModifyArg(
          method = "targetMethod",
          at = @At(
              value = "INVOKE",
              target = "Lsome/Class;someMethod(Ljava/lang/String;I)V"
          ),
          index = 1 // индекс изменяемого аргумента (начиная с 0)
      )
      private int modifyArgument(int originalValue) {
          // Получаем оригинальное значение, возвращаем модифицированное
          return originalValue * 2;
      }

      Где интуитивно всё понятно, логика такая же, только index обозначает какой аргумент(начиная с 0) редактируем.

    2. Сразу всех:

      @ModifyArgs, вот пример:

      @ModifyArgs - изменение нескольких аргументов
      java
      @ModifyArgs(
          method = "targetMethod",
          at = @At(
              value = "INVOKE", 
              target = "Lsome/Class;someMethod(FF)V"
          )
      )
      private void modifyMultipleArguments(Args args) {
          // args позволяет работать со всеми аргументами вызова
          float arg1 = args.get(0);
          float arg2 = args.get(1);
          
          args.set(0, arg1 * 1.5f); // Модифицируем первый аргумент
          args.set(1, arg2 + 10.0f); // Модифицируем второй аргумент
      }

      Думаю тут и так всё понятно.

    3. Локальные переменные:

      @ModifyVariable , позволяет перехватывать локальные переменные - пример:

      @ModifyVariable(
          method = "calculateDamage",
          at = @At(value = "LOAD",ordinal = 2)
          print = true // Отладочный вывод
      )

      Варианты @At:

      1. "LOAD" - при загрузке переменной

      2. "STORE" - при сохранении в переменную

      3. "HEAD" - в начале метода

    4. ordinal это какой по счёту действие.

    5. Константы:

      @ModifyConstant , позволяет найти по типу константы и её значению все поля и заменить через функцию обработчик. Пример:

      @ModifyConstant(
          method = "someMethod",
          constants = {
              @Constant(intValue = 16),
              @Constant(intValue = 32)
          }
      )
      private int replaceMultiple(int original) {
          return original * 2;
      }

      Собственно указываем сколько угодно @Constant, а вот список типов:

      1. intValue - целые числа

      2. floatValue - числа с плавающей точкой

      3. doubleValue - double числа

      4. longValue - long числа

      5. stringValue - строки

      6. charValue - символы

      7. classValue - классы

      8. nullValue - null значения

      9. expandZero - все нулевые значения(принимает true или false)

      ну а через = значение.

      Ну а функция обработчик всё понятно, тип тот который обрабатываем, название original. Возвращает тот же тип что и обрабатывает.

    6. Получение доступ к полю, вместо @Shadow:

      Использование @Accessor, по сути создаёт геттер и сеттер для любого поля, но при этом реализация скрыта, и через вызов функции манипулируете полем, синтаксис-пример:

      @Accessor("имя_поля")
      Тип getИмяполя();
      
      @Accessor("имя_поля") 
      void setИмяполя(ТипПоля значение);

      В функции имена поля с большой в аннотации как в классе.

    7. Получение доступ к функции, (Как с полем, только к функции):

      Собственно @Invoker позволяет получить доступ к полю, пример синтаксис-внизу:
      @Invoker("имя_метода") ReturnType callMethodName(Параметры...);
      в скобках оригинальный метод, ну а название метода, в начале invoke, и с большой буквы слитно название оригинального метода

    8. Изменение константных полей:
      Получив доступ через @Shadow , указывал @Final ещё нужно указать @Mutable(третьим аргументом) и тогда сможете изменить значение. И всё, меняйте хоть сразу, хоть через логику.

    9. Добавление новых полей и методов без конфликтов с классом(существуют сугубо в миксине). Просто напишите @Unique

    10. Возможно вы не уверены, что класс существует , тогда перед @Mixin укажите @Pseudo , а в @Mixin

    11. Так же ещё есть @Intrinsic, в скобка значение displace если установить true, полностью переопределит метод, иначе просто добавит функцию к целевой функции. При чём надо полностью сохранять модификатор доступа, возвращаемый тип, название и аргументы функции.

Собственно на этом все аннотации, на самом деле ничего сложного и есть огромный плюс: Mixin отдельная библиотека и не зависит от загрузчика, то есть мод полностью построенный на Mixin, легко перенесётся на Forge и т.п. А так же, позволяет полностью менять механики и добавлять совершенно новые, в отличие от API.

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