WENOPAL

08 · Leer el código de Java

Cómo abrir projectzomboid.jar con búsqueda de cadenas, javap y un decompilador para saber qué hace realmente el juego.

Curso técnico · Build 42 · Actualizado el

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:

sh
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):

sh
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/:

PaqueteClasesQué hayPara qué lo vas a abrir
zombie.core733Motor: render, texturas, aleatorios (core.random.Rand), matemáticasCómo se cargan las texturas, cómo funciona ZombRand
zombie.iso628El mundo: celdas, cuadros, objetos del mapaPuertas, ventanas, construcciones, IsoThumpable
zombie.network601MultijugadorQué se sincroniza solo y qué no (módulo 12)
zombie.scripting385Los parsers de los .txt y lógica de recetasQué claves acepta cada script; RecipeCodeOnCreate
zombie.characters321Jugador, zombis, animales, habilidadesDañ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 KahluaSolo 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:

sh
cd ~/pz-java/jar
grep -rla --include=*.class 'Rotten' zombie       # qué clases mencionan "Rotten"
grep -rla --include=*.class 'OnFillContainer' zombie

grep -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:

sh
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

Leer 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ónQué hace
aload_0Empuja this (en un método de instancia, la variable 0 es this)
iload_1, fload_2, aload_3Empuja la variable local 1 (int), 2 (float), 3 (objeto). Los parámetros son las primeras variables
istore 4Saca un int de la pila y lo guarda en la variable 4
getfield #7 // Field id:ILee un campo del objeto (aquí id, tipo I = int)
putfieldEscribe un campo
getstaticLee un campo estático (SandboxOptions.instance)
ldc "texto"Empuja una constante
invokevirtual / invokestaticLlama un método; el comentario dice cuál y su firma
iadd, isub, fmulSuma, resta, multiplica (prefijo: i int, f float, d double)
if_icmpne 7Si los dos ints de arriba son distintos, salta a la instrucción 7
ifeq, ifneSi el valor es 0 / no es 0 (los boolean son ints)
ireturn, areturn, returnDevuelve int / objeto / nada
new, dup, invokespecial <init>Crear un objeto (siempre van juntos)
athrowLanza 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: ireturn

Traducido: 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:

java
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:

java
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:

java
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.000
  • chance es el número que pones en la distribución: con chance = 10 y 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 rolls tiradas por contenedor (multiplicadas por la opción de sandbox), y en cada una se prueba cada ítem.
  • getLootModifier decide 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ón ZombiePopLootEffect. Por eso en zonas con muchos zombis los ítems raros mejoran mucho más, en proporción, que los comunes.
  • getBaseChanceMultiplier solo tiene dos casos: basura (isJunk) ×1,4 y los ítems con tag MoreWhenNoZombies ×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 de LuaManager$GlobalObject con @LuaMethod(name = "..."); el nombre en Java puede ser otro (instanceof es instof). Busca el name="..." con javap -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 -c de cada uno):
sh
for f in $(grep -rla --include=*.class 'minorCondition' zombie); do echo $f; done

Ejercicios

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 %.

HabilidadNext(...)condPercCondición final
0[5, 10)5 a 9siempre 50 %
3[35, 70)35 a 6950 % 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:

  1. ¿Qué devuelve RandAbstract.Next(int, Random) (el de un parámetro)?
  2. 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

  1. Crea en CursoLab una distribución procedural de prueba con un solo ítem a chance = 10 y rolls = 1, y asígnala a un contenedor fácil de encontrar (busca cómo lo hace el juego en ProceduralDistributions.lua y Distributions.lua).
  2. Con un script de consola, llena ese contenedor 1.000 veces (pista: ItemPickerJava.fillContainer está expuesto; vacía el contenedor entre llenadas) y cuenta cuántas veces salió el ítem.
  3. Compara con el 10 % que predice la fórmula. Repite con ZombiePopLootEffect en 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).

  1. ¿Hay nombres alternativos que el módulo 04 no menciona?
  2. ¿Se buscan para cualquier ítem o solo para algunos?
  3. ¿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, usa media/inventory/Question_On.png (el signo de pregunta).
  • Solo si el ítem es de tipo FOOD, busca variantes en este orden.
  • Podrido: Rotten, si no Spoiled, si no _Rotten.
  • Cocido: Cooked.
  • Quemado: Overdone primero, si no Burnt, 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:

  1. encuentra su método Java (anotación @LuaMethod en LuaManager$GlobalObject),
  2. decompílalo y resume en dos líneas qué hace,
  3. 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