Исследовать Java-классы и байткод для моддинга Build 42
Исследовать Java-классы и байткод Build 42
Автор утверждает, что Java-примеры модуля получены из .jar Build 42 за октябрь 2026 года и декомпилированы Vineflower 1.11.1. Он предупреждает, что номера строк и сама логика меняются между обновлениями. Здесь эту инспекцию не повторяли.
Подготовить инструменты
Для работы нужны JDK (в него входит javap, в отличие от Java из игры) и декомпилятор. Пример автора устанавливает OpenJDK в macOS, скачивает Vineflower 1.11.1 и распаковывает локальный .jar:
brew install openjdk # javap y java
mkdir -p ~/pz-java && cd ~/pz-java
curl -LO https://repo1.maven.org/maven2/org/vineflower/vineflower/1.11.1/vineflower-1.11.1.jar
mkdir -p jar && cd jar && unzip -oq ~/Zomboid/Workshop/_juego/projectzomboid.jar && cd ..
Для декомпиляции одного класса — автор считает, что это покрывает 95 % задач — он предлагает скопировать класс и вложенные классы и запустить Vineflower с -e=jar, чтобы декомпилятор использовал остальные классы как справочный материал:
mkdir -p uno/zombie/inventory dec
cp jar/zombie/inventory/ItemPickerJava*.class uno/zombie/inventory/ # la clase y sus clases internas ($)
java -jar vineflower-1.11.1.jar -e=jar uno/zombie/inventory dec/
# → dec/ItemPickerJava.java
Графическая альтернатива — IntelliJ IDEA Community, которая показывает декомпилированный .class. В статье предлагается добавить jar/ как библиотеку, переходить через Go to class (⌘O на macOS, Ctrl+N на Windows/Linux) и искать Find usages. Для цитирования и сравнения автор предпочитает файлы в dec/. Декомпиляция всего .jar занимает время и создаёт тысячи файлов; лучше идти класс за классом.
Карта .jar
Статья насчитывает 23 829 классов в .jar, из них 5 095 игровых классов в zombie/. Автор приводит такую карту пакетов:
| Пакет | Число классов по источнику | Содержимое и зачем изучать |
|---|---|---|
| zombie.core | 733 | Движок, рендеринг, текстуры, случайность (core.random.Rand), математика; загрузка текстур и ZombRand. |
| zombie.iso | 628 | Мир, клетки, квадраты и объекты карты; двери, окна, постройки, IsoThumpable. |
| zombie.network | 601 | Мультиплеер и синхронизация (модуль 12). |
| zombie.scripting | 385 | Парсеры .txt и логика рецептов; допустимые ключи и RecipeCodeOnCreate. |
| zombie.characters | 321 | Игроки, зомби, животные и навыки; урон, черты и XP. |
| zombie.inventory | Не указано | Предметы, контейнеры, loot и ItemPickerJava. |
| zombie.Lua | Не указано | Связь с Lua: LuaManager и LuaEventManager. |
| se.krka.kahlua | Не указано | Интерпретатор Kahlua; полезен при непонятных ошибках Lua. |
Уровень 1: искать строки
Начните с поиска строки, связанной с наблюдаемым поведением: имени файла, ключа скрипта, события или sandbox-настройки. Это покажет, какие классы стоит изучить. В источнике приведены Rotten и OnFillContainer:
cd ~/pz-java/jar
grep -rla --include=*.class 'Rotten' zombie # qué clases mencionan "Rotten"
grep -rla --include=*.class 'OnFillContainer' zombie
grep -a трактует бинарный файл как текст, потому что строковые литералы хранятся в constant pool. Автор предупреждает: ugrep в некоторых установках macOS может пропускать бинарные файлы. Если поиск ничего не находит, он предлагает pzxref.py str из модуля 08b, который ищет в выводе javap.
Уровень 2: javap и байткод
javap показывает содержимое класса, но не декомпилирует его. Источник показывает параметры для публичных сигнатур, приватных членов и полей, а также байткода:
javap -cp jar -public zombie.inventory.ItemPickerJava # firmas públicas
javap -cp jar -p zombie.inventory.ItemPickerJava # + privadas y campos
javap -cp jar -p -c zombie.core.random.RandAbstract # + bytecode de cada método
JVM описывается как стековая машина: инструкции извлекают значения из стека и помещают результаты. Статья предлагает начать со следующего набора операций:
| Инструкция | Описание в источнике |
|---|---|
| aload_0 | Помещает this в стек; у метода экземпляра локальная переменная 0 — this. |
| iload_1, fload_2, aload_3 | Помещают локальную переменную (int, float или объект); параметры занимают первые локальные переменные. |
| istore 4 | Извлекает int из стека и сохраняет в переменную 4. |
| getfield #7 // Field id:I | Читает поле id объекта; I означает int. |
| putfield | Записывает поле объекта. |
| getstatic | Читает статическое поле, например SandboxOptions.instance. |
| ldc "текст" | Помещает константу в стек. |
| invokevirtual / invokestatic | Вызывает метод; комментарий указывает метод и сигнатуру. |
| iadd, isub, fmul | Складывает, вычитает или умножает; префиксы i, f, d означают int, float и double. |
| if_icmpne 7 | Переходит к инструкции 7, если два верхних int различаются. |
| ifeq, ifne | Переход при нуле или ненулевом значении; boolean здесь представлен int. |
| ireturn, areturn, return | Возвращает int, объект или ничего. |
| new, dup, invokespecial <init> | Последовательность создания объекта. |
| athrow | Выбрасывает исключение. |
Краткая таблица дескрипторов статьи: I — int, F — float, D — double, Z — boolean, V — void, Ljava/lang/String; — объект, [I — массив. Например, (IILjava/util/Random;)I принимает два int и Random и возвращает int.
Автор вручную разбирает ZombRand(min, max): Lua-функция GlobalObject.ZombRand(double, double) преобразует аргументы в int через d2i (усечение), вызывает RandLua.Next(int, int), а затем zombie.core.random.RandAbstract.Next(int, int, Random). Приведённый байткод:
0: iload_2 // max
1: iload_1 // min
2: if_icmpne 7 // si max != min, sigue en 7
5: iload_1 // si son iguales: devuelve min
6: ireturn
7: iload_1
8: iload_2
9: if_icmple 20 // si min <= max, salta a 20
12: iload_1 // si no: los intercambia (variable 4 de auxiliar)
13: istore 4
15: iload_2
16: istore_1
17: iload 4
19: istore_2
20: ... (incrementa un contador interno id, lo vuelve a 0 al llegar a 10000)
45: aload_3 // el Random
46: iload_2 // max
47: iload_1 // min
48: isub // max - min
49: invokevirtual Random.nextInt(I)I // número en [0, max - min)
52: istore 4
54: iload 4
56: iload_1
57: iadd // + min
58: ireturn
Интерпретация автора: Next(min, max) возвращает целое в диапазоне [min, max), включая min, но не включая max; переставляет границы, если они заданы в обратном порядке, и возвращает min, если границы равны. Поэтому, по статье, ZombRand(1, 6) не возвращает 6. Источник также утверждает, что Java Rand.Next(a, b) использует тот же метод. Выводы относятся к конкретному .jar и здесь не проверялись.
Уровень 3: декомпиляция
Когда нужно понять целую логику, байткод читать медленно. Декомпилятор выводит Java, похожую на исходную, но без комментариев; имена локальных переменных могут быть выдуманы. В примере RecipeCodeOnCreate.minorCondition, которую, по подсчёту автора, используют 31 рецепт:
public static void minorCondition(CraftRecipeData data, IsoGameCharacter character) {
Perk highestSkill = data.getRecipe().getHighestRelevantSkill(character);
if (highestSkill == null) {
highestSkill = data.getRecipe().getHighestRelevantSkillFromXpAward(character);
}
int skill = character.getPerkLevel(highestSkill);
for (InventoryItem result : data.getAllCreatedItems()) {
int condPerc = PZMath.clamp(Rand.Next(5 + skill * 10, 10 + skill * 20), 5, 100);
int cond = Math.round(Math.max(result.getConditionMax() * (condPerc / 100.0F), result.getConditionMax() / 2.0F));
if (skill >= 10) {
cond = result.getConditionMax();
}
result.setCondition(cond);
result.syncItemFields();
}
}
Пример берёт самый высокий релевантный навык (или навык по награде XP), рассчитывает случайный процент, применяет минимум 50 %, устанавливает максимальную прочность при навыке 10 и выше и синхронизирует поля предмета.
Разбор: как игра рассчитывает loot
Вопрос руководства: от чего зависит появление предмета при первом открытии контейнера? Первый шаг — проследить от данных Distributions.lua до класса zombie/inventory/ItemPickerJava.class. Затем найти Rand.Next внутри doRollItemInternal. Источник приводит упрощённый фрагмент:
int rolls = (int)(containerDist.rolls * SandboxOptions.instance.rollsMultiplier.getValue());
rolls = Math.max(rolls, 1);
for (int m = 0; m < rolls; m++) {
for (ItemPickerItem item : containerDist.items) { // (simplificado)
if (Rand.Next(10000) < getActualSpawnChance(item, character, container, zombieDensity, isJunk)) {
InventoryItem spawnItem = tryAddItemToContainer(container, itemName, containerDist);
if (spawnItem == null) {
return; // ¡contenedor lleno: se corta todo!
}
...
Цикл проходит по попыткам и предметам. Если случайное значение меньше getActualSpawnChance, игра пытается положить предмет. Если контейнер заполнен и операция возвращает null, показанный код делает return и прерывает процесс.
Далее статья показывает getActualSpawnChance: она учитывает sandbox-модификатор, базовый шанс, плотность зомби и временной множитель:
float lootModifier = getLootModifier(itemName, isJunk); // opción de sandbox según el tipo de loot
if (lootModifier == 0.0F) return 0.0F; // "ninguno" o ítem en la lista de exclusión
float baseChance = getBaseChance(item, character, isJunk); // el número de Distributions.lua
zombieDensity = getAdjustedZombieDensity(zombieDensity, scriptItem, isJunk);
float overTimeModifier = SandboxOptions.instance.getCurrentLootMultiplier(...); // el loot que baja con los días
return (baseChance * 100.0F * lootModifier + zombieDensity) * overTimeModifier;
Результат сравнивается с Rand.Next(10000). Формулу автор записывает так:
probabilidad por tirada = (chance × 100 × modificadorDeSandbox + densidadDeZombis) × modificadorPorTiempo / 10.000
- chance — значение из распределения. В упрощённом примере источника получается 10 % на попытку при эффективном базовом chance 10, модификаторе sandbox 1, нулевой плотности зомби и множителе времени 1.
- На контейнер приходится rolls попыток с учётом sandbox-настройки; в каждой проверяется каждый предмет.
- getLootModifier выбирает множитель по категории предмета: еда, оружие, инструменты, книги и т. п.
- Плотность зомби прибавляется, а не умножается; автор указывает предел zombieDensityCap = 8.0 × ZombiePopLootEffect и делает вывод, что в зонах с множеством зомби редкие предметы улучшаются пропорционально сильнее обычных.
- По статье, getBaseChanceMultiplier имеет два случая: мусор isJunk ×1,4 и предметы с тегом MoreWhenNoZombies ×2, если в сохранении нет зомби. Утверждается, что черты персонажа расчёт loot не меняют.
Источник говорит, что формулу следует проверить в игре, и отсылает к упражнению 8.3. Ещё один описанный вызов Lua перед завершением заполнения — LuaEventManager.triggerEvent("OnFillContainer", roomName, container.getType(), container); автор предлагает его как чистую точку изменения уже заполненных контейнеров.
Как исследовать код, не потерявшись
- Начинайте с наблюдаемого элемента — строки, события или функции Lua — и прослеживайте его. Не открывайте сразу огромный класс без конкретного вопроса.
- Чтобы связать глобальную Lua-функцию с Java, ищите в LuaManager$GlobalObject аннотацию @LuaMethod(name = "..."). Java-имя может отличаться: instanceof называется instof. Статья советует javap -v.
- Записывайте класс, метод и версию игры.
- Чтобы найти классы, где упоминается метод, источник предлагает цикл ниже, а затем javap -c для каждого результата:
for f in $(grep -rla --include=*.class 'minorCondition' zombie); do echo $f; done
Упражнения
Упражнение 8.1 · Прочность изготовленного предмета
По коду minorCondition и диапазону Rand.Next вычислите процент прочности для навыков 0, 3, 5 и 10, затем проверьте в игре на рецепте с RecipeCodeOnCreate.minorCondition. Ответ таблицы из источника:
| Навык | Диапазон Next(...) | condPerc | Итоговая прочность по источнику |
|---|---|---|---|
| 0 | [5, 10) | 5–9 | Всегда 50 % |
| 3 | [35, 70) | 35–69 | 50–69 % |
| 5 | [55, 110) | 55–100 с ограничением | 55–100 % |
| 10 | — | — | Всегда 100 % |
Комментарий статьи: при низком навыке минимальный порог 50 % приводит к одинаковому результату.
Упражнение 8.2 · Разобрать байткод вручную
С помощью javap -p -c выясните, что возвращает RandAbstract.Next(int, Random), при каком условии LuaManager.validateReflectionAccess(Object) выдаёт «Not in debug» и какие объекты он отвергает даже в debug-режиме. Объясните каждую инструкцию второго метода, затем сравните с декомпиляцией.
Упражнение 8.3 · Проверить формулу loot
- Создайте в CursoLab тестовое procedural-распределение с одним предметом, chance = 10 и rolls = 1, и привяжите его к легко находимому контейнеру.
- Скриптом консоли заполните контейнер 1 000 раз, очищая между попытками, и сосчитайте предметы. Источник упоминает ItemPickerJava.fillContainer.
- Сравните результат с ожидаемыми 10 % и повторите с ZombiePopLootEffect = 0.
Критерий источника — отклонение менее двух процентных пунктов либо объяснение расхождения по коду. Эксперимент не проводился.
Упражнение 8.4 · Иконки еды
Проверьте в Item.DoParam формирование имени иконки, альтернативные суффиксы, типы предметов, для которых они ищутся, и запасную текстуру. Решение статьи: основное имя — "Item_" + icon; если файл не найден, используется media/inventory/Question_On.png. Варианты ищутся только для FOOD. Испорченное: Rotten, затем Spoiled, затем _Rotten. Приготовленное: Cooked. Подгоревшее: Overdone, затем Burnt, затем _Burnt. Также строится имя текстуры предмета в мире media/inventory/world/WItem_<icon>.png с теми же суффиксами. Автор говорит, что таблица модуля 04 была верной, но неполной.
Упражнение 8.5 · Из Lua в Java
Выберите три используемые глобальные Lua-функции, например getText, sendClientCommand и getFileWriter. Найдите соответствующий Java-метод с @LuaMethod в LuaManager$GlobalObject, декомпилируйте его, кратко опишите и запишите неизвестное ранее ограничение, проверку или случай возврата nil.
Задача со звёздочкой 8
Создайте pzwhere.sh <имя>, который получает строку (ключ скрипта, событие, имя Lua-функции) и показывает игровые классы, содержащие её, метод каждого класса по javap -c и Java-метод для глобальной Lua-функции. Используйте скрипт, чтобы выяснить, где читается DaysFresh и кто использует значение.