Objetivo: abrir projectzomboid.jar y responder solo cualquier pregunta del tipo "¿cómo lo hace el juego realmente?": qué fórmula usa, qué archivos busca, quién llama a qué. Vas a usar tres herramientas de menor a mayor potencia: búsqueda de cadenas, javap (bytecode) y un decompilador.
1. Preparar el taller
Necesitas un JDK (trae javap; el Java del juego no) y un decompilador:
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 ..Decompilar una clase (lo que vas a hacer el 95 % del tiempo):
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-e=jar le da al decompilador el resto del juego como referencia (sin eso, los tipos salen peor). Decompilar el .jar entero se puede, pero tarda y genera miles de archivos que no vas a leer: ve clase por clase.
Alternativa con interfaz: IntelliJ IDEA Community abre un .class y lo muestra decompilado (usa el mismo motor). Agrega jar/ como biblioteca de un proyecto vacío y navega con Go to class (⌘O en macOS, Ctrl+N en Windows y Linux) y Find usages. Para explorar es más cómodo; para citar y comparar, los archivos de dec/.
2. El mapa del .jar
El .jar tiene 23.829 clases, pero solo 5.095 son del juego (zombie/). El resto son bibliotecas (org/, com/, gnu/, kotlin/...) que no te interesan. Dentro de zombie/:
| Paquete | Clases | Qué hay | Para qué lo vas a abrir |
|---|---|---|---|
zombie.core | 733 | Motor: render, texturas, aleatorios (core.random.Rand), matemáticas | Cómo se cargan las texturas, cómo funciona ZombRand |
zombie.iso | 628 | El mundo: celdas, cuadros, objetos del mapa | Puertas, ventanas, construcciones, IsoThumpable |
zombie.network | 601 | Multijugador | Qué se sincroniza solo y qué no (módulo 12) |
zombie.scripting | 385 | Los parsers de los .txt y lógica de recetas | Qué claves acepta cada script; RecipeCodeOnCreate |
zombie.characters | 321 | Jugador, zombis, animales, habilidades | Daño, rasgos, XP |
zombie.inventory | — | Ítems, contenedores, loot (ItemPickerJava) | Cómo se llenan los contenedores |
zombie.Lua | — | El puente con Lua (LuaManager, LuaEventManager) | Módulo 07 |
se.krka.kahlua | — | El intérprete Kahlua | Solo si un error de Lua no tiene sentido |
3. Nivel 1: buscar cadenas
La técnica más barata. Casi todo comportamiento visible tiene una cadena: un nombre de archivo, una clave de script, un nombre de evento, una opción de sandbox. Busca la cadena en los .class y sabrás qué clases mirar:
cd ~/pz-java/jar
grep -rla --include=*.class 'Rotten' zombie # qué clases mencionan "Rotten"
grep -rla --include=*.class 'OnFillContainer' zombiegrep -a trata el binario como texto: las cadenas literales de una clase están tal cual en su constant pool. Ojo: si tu grep es ugrep, como en algunas instalaciones de macOS, puede saltarse los binarios; si no encuentra nada, usa pzxref.py str (módulo 08b), que busca en la salida de javap.
4. Nivel 2: javap y el bytecode
javap no decompila: muestra lo que hay. Tres modos:
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étodoLeer bytecode suena peor de lo que es. La JVM es una máquina de pila: cada instrucción saca valores de la pila y empuja el resultado. Con 15 instrucciones lees casi todo:
| Instrucción | Qué hace |
|---|---|
aload_0 | Empuja this (en un método de instancia, la variable 0 es this) |
iload_1, fload_2, aload_3 | Empuja la variable local 1 (int), 2 (float), 3 (objeto). Los parámetros son las primeras variables |
istore 4 | Saca un int de la pila y lo guarda en la variable 4 |
getfield #7 // Field id:I | Lee un campo del objeto (aquí id, tipo I = int) |
putfield | Escribe un campo |
getstatic | Lee un campo estático (SandboxOptions.instance) |
ldc "texto" | Empuja una constante |
invokevirtual / invokestatic | Llama un método; el comentario dice cuál y su firma |
iadd, isub, fmul | Suma, resta, multiplica (prefijo: i int, f float, d double) |
if_icmpne 7 | Si los dos ints de arriba son distintos, salta a la instrucción 7 |
ifeq, ifne | Si el valor es 0 / no es 0 (los boolean son ints) |
ireturn, areturn, return | Devuelve int / objeto / nada |
new, dup, invokespecial <init> | Crear un objeto (siempre van juntos) |
athrow | Lanza una excepción |
Las firmas usan descriptores: I int, F float, D double, Z boolean, V void, Ljava/lang/String; un objeto, [I un arreglo. (IILjava/util/Random;)I es "recibe int, int, Random y devuelve int".
Ejemplo leído a mano: ZombRand(min, max)
ZombRand(min, max) en Lua es GlobalObject.ZombRand(double, double): convierte los dos números a int con d2i (trunca) y llama a RandLua.Next(int, int), que hereda de zombie.core.random.RandAbstract. Ahí está la lógica, en Next(int, int, Random). Su bytecode completo:
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: ireturnTraducido: Next(min, max) devuelve un entero en [min, max): incluye min, excluye max. Si los pasas al revés los da vuelta, y si son iguales devuelve min. Ese "excluye max" es un bug clásico de mods (ZombRand(1, 6) nunca da 6). El Rand.Next(a, b) que usa el Java del juego pasa por el mismo método, así que se comporta igual.
5. Nivel 3: decompilar
Cuando quieres entender una lógica completa, el bytecode es lento. El decompilador te da Java casi original (sin comentarios y con nombres de variables locales a veces inventados). Por ejemplo, lo que el módulo 03 dejó pendiente: RecipeCodeOnCreate.minorCondition, que usan 31 recetas:
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();
}
}Con esto ya puedes responder con exactitud "¿qué condición tiene lo que fabrico?" (ejercicio 8.1).
6. Caso guiado: cómo calcula el juego el loot
Pregunta: cuando abres un contenedor por primera vez, ¿de qué depende que salga un ítem?
Paso 1, encontrar la clase. El Lua de distribuciones (Distributions.lua) es datos; alguien los consume. grep -rla 'ProceduralDistributions' zombie lleva a zombie/inventory/ItemPickerJava.class.
Paso 2, encontrar el punto de decisión. Decompilada, la clase tiene 2.505 líneas. Busca Rand.Next: en doRollItemInternal está la línea clave:
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!
}
...Paso 3, seguir la fórmula. getActualSpawnChance:
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;Y se compara contra Rand.Next(10000). En palabras:
probabilidad por tirada = (chance × 100 × modificadorDeSandbox + densidadDeZombis) × modificadorPorTiempo / 10.000chancees el número que pones en la distribución: conchance = 10y loot "normal" (modificador 1), la probabilidad por tirada es 1.000 / 10.000 = 10 %. El número de la distribución es, directamente, un porcentaje por tirada.- Hay
rollstiradas por contenedor (multiplicadas por la opción de sandbox), y en cada una se prueba cada ítem. getLootModifierdecide el modificador por tipo de ítem (getLootType: comida, armas, herramientas, libros...): son las opciones de loot por categoría del sandbox.- La densidad de zombis suma un plano (no multiplica), con tope
zombieDensityCap = 8.0× la opciónZombiePopLootEffect. Por eso en zonas con muchos zombis los ítems raros mejoran mucho más, en proporción, que los comunes. getBaseChanceMultipliersolo tiene dos casos: basura (isJunk) ×1,4 y los ítems con tagMoreWhenNoZombies×2 si la partida no tiene zombis. Ningún rasgo del personaje cambia el loot en este cálculo.
Paso 4, comprobarlo. Una afirmación sacada del código se comprueba en el juego. El ejercicio 8.3 lo hace con un contenedor de prueba.
Y un detalle que solo ves leyendo: el loot dispara un evento de Lua antes de terminar, LuaEventManager.triggerEvent("OnFillContainer", roomName, container.getType(), container). Es el gancho limpio para modificar contenedores recién llenados (módulo 13).
7. Cómo trabajar sin perderse
- Empieza siempre desde algo observable (una cadena, un evento, una función de Lua) y camina hacia el código. Nunca "voy a leer
IsoPlayer": tiene miles de líneas. - De Lua a Java: una función global de Lua (
getPlayer,instanceof) es un método deLuaManager$GlobalObjectcon@LuaMethod(name = "..."); el nombre en Java puede ser otro (instanceofesinstof). Busca elname="..."conjavap -v. - Anota lo que encuentras en tus notas con la clase, el método y la versión del juego.
- ¿Quién llama a este método? No hay "find usages" en la terminal, pero esto funciona (y después
javap -cde cada uno):
for f in $(grep -rla --include=*.class 'minorCondition' zombie); do echo $f; doneEjercicios
Ejercicio 8.1 · La condición de lo que fabricas
Con el código de minorCondition de la sección 5 (y lo que sabes de Rand.Next por la sección 4), calcula el rango de condición (en % del máximo) de un ítem fabricado con habilidad 0, 3, 5 y 10. Después compruébalo en el juego con una receta que use RecipeCodeOnCreate.minorCondition (búscala en _juego/scripts).
Solución
condPerc = clamp(Next(5 + 10s, 10 + 20s), 5, 100) y la condición final es el máximo entre ese porcentaje y el 50 %.
| Habilidad | Next(...) | condPerc | Condición final |
|---|---|---|---|
| 0 | [5, 10) | 5 a 9 | siempre 50 % |
| 3 | [35, 70) | 35 a 69 | 50 % a 69 % |
| 5 | [55, 110) | 55 a 100 (con tope) | 55 % a 100 % |
| 10 | — | — | siempre 100 % |
Lo interesante: con habilidad baja no hay azar, siempre sale al 50 %. Ningún tooltip del juego lo dice.
Ejercicio 8.2 · Bytecode a mano
Sin decompilador, solo con javap -p -c, responde:
- ¿Qué devuelve
RandAbstract.Next(int, Random)(el de un parámetro)? - En
LuaManager.validateReflectionAccess(Object), ¿qué condición hace que lance"Not in debug"? ¿Y qué objetos rechaza aunque estés en debug?
Listo cuando: puedes explicar cada instrucción del segundo método. Después compara con la versión decompilada.
Ejercicio 8.3 · Comprobar la fórmula del loot
- Crea en CursoLab una distribución procedural de prueba con un solo ítem a
chance = 10yrolls = 1, y asígnala a un contenedor fácil de encontrar (busca cómo lo hace el juego enProceduralDistributions.luayDistributions.lua). - Con un script de consola, llena ese contenedor 1.000 veces (pista:
ItemPickerJava.fillContainerestá expuesto; vacía el contenedor entre llenadas) y cuenta cuántas veces salió el ítem. - Compara con el 10 % que predice la fórmula. Repite con
ZombiePopLootEffecten 0 para quitar la densidad de zombis.
Listo cuando: tu medición está a menos de 2 puntos de la predicción, o puedes explicar con el código por qué no.
Ejercicio 8.4 · Los íconos de la comida
El módulo 04 dice que el motor busca Item_XRotten, Item_XCooked y Item_XBurnt. Verifícalo en el código: busca dónde se arma el nombre (pista: grep -rla 'Rotten' y después mira Item.DoParam, en la clave Icon).
- ¿Hay nombres alternativos que el módulo 04 no menciona?
- ¿Se buscan para cualquier ítem o solo para algunos?
- ¿Qué textura se usa si el ícono principal no existe?
Solución
En Item.DoParam, al leer Icon:
- El nombre es
"Item_" + icon. Si no existe, usamedia/inventory/Question_On.png(el signo de pregunta). - Solo si el ítem es de tipo
FOOD, busca variantes en este orden. - Podrido:
Rotten, si noSpoiled, si no_Rotten. - Cocido:
Cooked. - Quemado:
Overdoneprimero, si noBurnt, si no_Burnt. - También arma el nombre de la textura en el suelo:
media/inventory/world/WItem_<icon>.png, con los mismos sufijos.
O sea, la tabla del módulo 04 es correcta pero incompleta.
Ejercicio 8.5 · De Lua a Java
Elige tres funciones globales de Lua que uses (por ejemplo getText, sendClientCommand, getFileWriter). Para cada una:
- encuentra su método Java (anotación
@LuaMethodenLuaManager$GlobalObject), - decompílalo y resume en dos líneas qué hace,
- anota algo que no sabías (una validación, un caso en que devuelve
nil, un límite).
Desafío 8
Escribe pzwhere.sh <nombre>: recibe una cadena (una clave de script, un evento, un nombre de función de Lua) y lista las clases del juego que la contienen, el método de cada una donde aparece (usando javap -c) y, si es una función global de Lua, su método Java. Úsalo para responder: ¿dónde se lee la clave DaysFresh y quién usa ese valor?
Anterior: 07 · La frontera Lua ↔ Java · Siguiente: 08b · Bytecode a fondo