Перейти к содержанию

Исследовать Java-классы и байткод для моддинга Build 42

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

Исследовать 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.core733Движок, рендеринг, текстуры, случайность (core.random.Rand), математика; загрузка текстур и ZombRand.
zombie.iso628Мир, клетки, квадраты и объекты карты; двери, окна, постройки, IsoThumpable.
zombie.network601Мультиплеер и синхронизация (модуль 12).
zombie.scripting385Парсеры .txt и логика рецептов; допустимые ключи и RecipeCodeOnCreate.
zombie.characters321Игроки, зомби, животные и навыки; урон, черты и 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–6950–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 и кто использует значение.

Информация
Счётчики, диапазоны, формулы и описания реализации относятся к утверждениям страницы о конкретном .jar и могут измениться в патчах. Некоторые исходные фрагменты сокращены многоточиями. Команды, декомпилятор, игра и упражнения не запускались; файлы игры не распространяются. Примеры установки OpenJDK и пути адаптированы под macOS.