WENOPAL

08b · Bytecode a fondo: qué sacar de él y para qué

Usar el bytecode del juego como fuente de datos: valores por defecto, quién llama a quién, qué eventos se disparan de verdad y qué cambió entre versiones.

Curso técnico · Build 42 · Actualizado el

Objetivo: usar el bytecode del juego como fuente de datos, no solo como último recurso. El módulo 08 te enseñó a leer un método. Este te enseña a preguntarle cosas al .jar entero: valores por defecto, quién llama a quién, qué eventos existen de verdad, qué cambió entre versiones. Todo con ejemplos reales de Build 42 (versión de octubre de 2026).

1. ¿Por qué bytecode si existe el decompilador?

El decompilador te da Java legible, y para entender una función es lo mejor. El bytecode gana en todo lo demás:

Necesitas…DecompiladorBytecode (javap)
Entender la lógica de un métodomejorlento
Un valor exacto (10.0f, 0x2, un texto)a veces lo reescribe o lo calculaliteral, tal cual
Buscar en las 5.095 clases del juego a la vezlento (horas)minutos
Anotaciones (@LuaMethod, @PacketSetting)a veces las omitejavap -v las muestra todas
Saber qué sobrecarga se llama exactamentepuede confundirsela firma exacta en cada llamada
Cuando el decompilador falla o inventa—el bytecode no miente: es lo que corre

La regla práctica: bytecode para buscar y para datos exactos; decompilador para leer la lógica que encontraste.

2. Anatomía de un .class

javap -v muestra todo lo que tiene una clase:

Constant pool:                         ← todas las constantes: textos, números, nombres de clases y métodos
   #921 = String  #922                 //  LevelForDismantleXPCutoff
   ...
{
  public static zombie.characters.IsoPlayer getPlayer();      ← cada método
    descriptor: ()Lzombie/characters/IsoPlayer;               ← su firma en formato JVM
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: getstatic ...                                     ← las instrucciones
    LineNumberTable: ...                                      ← línea del .java original
    RuntimeVisibleAnnotations:                                ← anotaciones
      se.krka.kahlua.integration.annotations.LuaMethod(
        name="getPlayer"
        global=true
      )
}

Tres cosas que vas a usar todo el tiempo:

  • El constant pool tiene todos los textos literales de la clase. Por eso buscar un texto en los .class funciona (módulo 08, sección 3).
  • Las anotaciones son metadatos que el motor lee al arrancar. En PZ dicen qué funciones ve Lua (@LuaMethod) y qué permisos pide cada paquete de red (@PacketSetting).
  • LineNumberTable relaciona cada instrucción con su línea en el .java original: un stack trace de Java te da esas líneas.

Construcciones que vas a encontrar

Ves en el bytecodeEra en Java
<init>un constructor
static {};el inicializador estático: aquí viven las constantes y los valores iniciales de los campos estáticos
Clase$Interna, Clase$1clase interna / anónima (LuaManager$GlobalObject, ItemPickerJava$KeyNamer)
lambda$metodo$0una lambda dentro de metodo
invokedynamic ... makeConcatWithConstants"texto" + variable
String.hashCode + lookupswitch + String.equalsun switch sobre un texto
checkcast / instanceofun cast / un instanceof

Ejemplo del switch sobre texto, en ItemPickerJava$KeyNamer.getName:

 93: invokevirtual String.hashCode()
 96: lookupswitch {           // salta según el hash...
       -1898802882: 148
       -1896111701: 132
           2049469: 164
           default: 177 }
132: aload 4
134: ldc "Prison"             // ...y confirma con equals (dos textos pueden tener el mismo hash)
136: invokevirtual String.equals

Los casos del switch están en los ldc que siguen a cada salto: así sacas la lista completa de valores que acepta.

3. Qué le puedes preguntar al .jar: casos de uso

Cada caso tiene la pregunta, cómo se responde y lo que salió en nuestra versión.

3.1 Valores por defecto y constantes

Pregunta: ¿qué pasa si no pongo distanceMin en un sonido? ¿Cuál es el tope de densidad de zombis en el loot?

Cómo: el constructor (<init>) o el static {} de la clase.

$ javap -p -c zombie.audio.GameSoundClip      # constructor
   5: fconst_1;  6: putfield volume           → 1.0
  15: ldc 10.0f; 17: putfield distanceMin     → 10.0   ← el caso del Sable de Luz (módulo 16)
  21: ldc 10.0f; 23: putfield distanceMax     → 10.0

$ javap -p -c zombie.inventory.ItemPickerJava  # static {}
  10: ldc 8.0f;  13: putstatic zombieDensityCap  → 8.0

Las opciones de sandbox del juego salen igual. En el constructor de zombie.SandboxOptions hay 183 llamadas newXxxOption (33 booleanas, 50 decimales, 72 de lista, 26 enteras, 2 de texto), cada una con nombre, mínimo, máximo y valor por defecto:

2452: ldc "LevelForDismantleXPCutoff"
2455: iconst_0          ← mínimo 0
2456: bipush 10         ← máximo 10
2458: iconst_0          ← por defecto 0
2459: invokevirtual newIntegerOption(String, int, int, int)

Ese 0 por defecto es exactamente por qué "desarmar muebles no da XP" (caso 4 del módulo 16).

3.2 La firma exacta y qué conversión se hace

Pregunta: ¿qué recibe realmente ZombRand(a, b) desde Lua?

Cómo: el método en LuaManager$GlobalObject. Lo viste en el módulo 08: ZombRand(double, double) hace d2i a cada argumento (trunca) y llama RandLua.Next(int, int), que da un número en [a, b). Sin el bytecode no sabrías que ZombRand(1.9, 6.9) es ZombRand(1, 6).

3.3 Condiciones exactas

Pregunta: ¿en qué casos hace algo una función?

Tres respuestas que solo dio el bytecode (módulos 07, 09 y 12):

  • sendServerCommand: getstatic GameServer.server; ifeq → return. En un jugador no hace nada.
  • IsoGameCharacter.autoDrink: si TriggerHook("AutoDrink") devuelve true, return. Registrar un Hook apaga la lógica de Java.
  • LuaManager.validateReflectionAccess: getstatic Core.debug; ifne → si no es debug, "Not in debug".

3.4 Quién llama a quién

Pregunta: ¿desde dónde se dispara un gancho? ¿Quién usa este campo?

$ python3 pzxref.py callers LuaHookManager.TriggerHook
zombie.ai.states.SwipeStatePlayer   enter(IsoGameCharacter)
zombie.characters.IsoGameCharacter  Hit(HandWeapon, IsoGameCharacter, float, boolean, float, boolean)
zombie.characters.IsoGameCharacter  calculateStats()
zombie.characters.IsoGameCharacter  autoDrink()
zombie.characters.IsoGameCharacter  triggerContextualAction(String)  (y 4 sobrecargas más)
zombie.characters.IsoLivingCharacter AttemptAttack(float)

$ python3 pzxref.py fields zombieDensityCap
get  ItemPickerJava.getZombieDensityFactor(...)
put  ItemPickerJava.static {}

El segundo dice que nadie más cambia el tope: no hay opción de sandbox ni función de Lua que lo toque.

3.5 Eventos: dónde se disparan y cuáles no existen de verdad

Esto es probablemente lo más útil para un modder. LuaEventManager.AddEvents registra los eventos (por eso Events.X.Add no da error), pero registrar no es disparar. Cruzando los registros con todas las llamadas a triggerEvent del .jar y del Lua del juego:

$ python3 pzxref.py events OnPlayerGetDamage
BodyDamage.Update()                       ← enfermedad, hambre...
BodyPart.DamageUpdate()                   ← sangrado, heridas
Nutrition.setWeight(double)
IsoGameCharacter.handleLandingImpact(...) ← caídas
IsoGameCharacter.FireCheck()              ← fuego
IsoGameCharacter.Hit(...)                 ← golpes
IsoGameCharacter.ReduceHealthWhenBurning()
IsoPlayer.onHitByVehicleApplyDamage(...)  ← atropellos
BaseVehicle.damagePlayers(float)          ← choques

Ahora sabes todas las fuentes de daño que avisan, y puedes mirar en cada una qué argumentos pasa (el tipo de daño).

Y lo contrario: eventos que están registrados pero que ni Java ni el Lua del juego disparan nunca con su nombre (en nuestra versión). Algunos de los que un modder buscaría:

EventoQué pasa
OnRainStartnunca se dispara. Su pareja OnRainStop sí (RainManager.stopRaining)
OnBeingHitByZombienunca se dispara: usa OnPlayerGetDamage o OnWeaponHitCharacter
OnMakeItemnunca se dispara, aunque un archivo del juego lo escucha
OnCharacterCreateStats, OnPreGameStart, OnPlayerSetSafehouse, OnAIStateEnter/Execute/Exitnunca se disparan

Y dos ganchos de Hook que tampoco: UseItem y WeaponSwingHitPoint (el evento OnWeaponSwingHitPoint sí existe y se dispara en CombatManager.attackCollisionCheck).

Si tu mod escucha uno de estos, nunca va a correr, y no hay ningún error que te avise. El ejercicio 8b.3 te pide comprobar uno en el juego.

3.6 Las claves que acepta un script

Pregunta: ¿qué claves acepta un item? ¿Una clave mal escrita da error?

Cómo: los textos del método que parsea (Item.DoParam(String, String)): 407 textos distintos, comparados con equalsIgnoreCase 440 veces (módulo 07). Una clave que no está en esa lista se ignora en silencio: el bytecode es la única lista completa. Lo mismo vale para mod.info (ChooseGameInfo), los sonidos (GameSoundScript) y cada tipo de script.

3.7 Permisos de red

Pregunta: ¿qué puede mandarle un jugador cualquiera al servidor?

Cómo: cada paquete de zombie.network.packets tiene una anotación @PacketSetting(requiredCapability = ...). Con javap -v sobre las 242 clases de paquetes:

Permiso pedidoPaquetes
LoginOnServer (cualquiera que entró)204
None5
permisos de admin o debug (ConnectWithDebug, TeleportToPlayer, AnimalCheats, RolesWrite...)el resto

Ejemplos: GlobalModDataPacket y AddItemInInventoryPacket piden solo LoginOnServer; SyncPlayerStatsPacket pide CanModifyBodyStats. Es la lista de lo que el servidor acepta de cualquiera, y por qué tu código de servidor tiene que validar (módulo 12).

3.8 Qué cambió entre versiones

Pregunta: el juego se actualizó, ¿qué se rompió?

Cómo: guarda la salida pública de cada versión y compara:

sh
javap -public -cp jar_42.21 $(cat clases_que_uso.txt) > api_42.21.txt
javap -public -cp jar_42.22 $(cat clases_que_uso.txt) > api_42.22.txt
diff api_42.21.txt api_42.22.txt        # métodos que desaparecieron o cambiaron de firma

Y con dos índices de pzxref (uno por versión) puedes comparar eventos, claves de scripts y valores por defecto. Es la versión automática del módulo 17, sección 3.

4. La herramienta: pzxref.py

Recorre las clases del juego con javap -p -c, guarda en una base SQLite cada texto, cada llamada, cada lectura o escritura de campo y cada evento disparado, y te deja consultarlos:

sh
python3 herramientas/pzxref.py build ~/pz-java/jar     # una vez por versión (~1 minuto, ~90 MB)
python3 herramientas/pzxref.py str Rotten.png           # quién usa un texto
python3 herramientas/pzxref.py callers ItemPickerJava.fillContainer
python3 herramientas/pzxref.py calls zombie/inventory/ItemPickerJava.getActualSpawnChance
python3 herramientas/pzxref.py fields zombieDensityCap
python3 herramientas/pzxref.py events                   # todos los eventos y ganchos con su cantidad de lugares
python3 herramientas/pzxref.py events OnFillContainer

Con nuestra versión: 5.095 clases, 55.101 textos, 326.047 llamadas y 211 eventos distintos disparados desde Java. Una consulta tarda menos de un segundo.

Cómo reconoce los eventos: en el bytecode, el nombre del evento es el primer argumento de triggerEvent, así que es el último texto conocido (uno de los que registran LuaEventManager y LuaHookManager) antes de la llamada.

Lo que no ve:

  • Llamadas por reflexión o nombres armados en tiempo de ejecución (un evento cuyo nombre se construye con + no aparece).
  • Lo que pasa en Lua: para eso, rg sobre _juego/lua. La lista de "eventos que nunca se disparan" de la sección 3.5 cruza las dos cosas.
  • Las bibliotecas fuera de zombie/ (puedes indexarlas cambiando prefix en build).

5. Qué gana tu mod con esto

Leer el .jar no es un fin. Sirve porque cambia decisiones concretas de tu mod. Esto es lo que te da, con ejemplos que ya salieron en el curso.

5.1 Bugs que no lanzan error

Los peores bugs de un mod son los silenciosos: no hay nada en el log, simplemente no pasa nada. El bytecode los encuentra antes de que un jugador los reporte.

SituaciónQué te dice el .jarQué ganas
Escuchas OnRainStart, OnMakeItem u OnBeingHitByZombieNadie los dispara (sección 3.5)No pierdes una tarde probando: usas otro evento desde el principio
Registras Hook.UseItemJava nunca lo llamaLo mismo, con ganchos
Agregas loot a la cocina con OnFillContainerTambién se dispara desde BaseVehicle.randomizeContainerFiltras por roomName y tu ítem no aparece en maleteros
Tu acción con barra no tiene completeJava marca useCustomRemoteTimedActionSync y la acción corre solo en el clienteSabes por qué "en multijugador no guarda" antes de probarlo
Mandas un objeto en sendClientCommandTableNetworkUtils solo manda texto, números, booleanos, tablas y unos pocos tipos del juego; lo demás se descarta con un aviso en el logMandas ids en vez de objetos

Rutina: antes de escribir Events.X.Add, corre pzxref events X y rg -n "triggerEvent\(\"X\"" _juego/lua. Si los dos están vacíos, ese evento no existe para ti.

5.2 Números elegidos, no adivinados

DecisiónDe dónde sale el númeroEjemplo
Peso de lootLa fórmula de doRollItemInternal (módulo 08)En Tocomples Food, uno de nuestros mods, un peso 50 es un 50 % por tirada: casi seguro en cada refrigerador
Rango de una opción de sandboxLos 183 newXxxOption del constructor de SandboxOptions (ejercicio 8b.1)Tus rangos se parecen a los del juego y el jugador no se sorprende
Condición de un ítem fabricadoRecipeCodeOnCreate.minorConditionSabes que nunca baja del 50 % y que con habilidad 10 sale perfecto
Duración de una acciónadjustMaxTimeTu acción se alarga sola con dolor de manos, borrachera o frío: no lo dupliques en tu getDuration
Valores al azarZombRand(a, b) trunca y devuelve de a a b-1Tu ZombRand(1, 6) nunca da 6

5.3 Mods que sobreviven a las actualizaciones

Cada parche de PZ puede cambiar firmas de métodos. Con la lista de clases que usa tu mod y un diff del API (sección 3.8, desafío 8b) sabes qué se rompe antes de que los jugadores lo reporten. Ejemplo real: IsoCell.getVehicles() ahora devuelve un Set, y el código que lo recorría como lista falla; getObjectListForLua() es el reemplazo.

Rutina tras cada actualización del juego: copiar el .jar nuevo, pzxref build, diff del API de tus clases, revisar pzxref events contra la versión anterior. Diez minutos en vez de esperar comentarios en Steam.

5.4 Multijugador sin agujeros

El .jar te dice qué controla el servidor y qué no:

  • 204 de 242 paquetes solo piden haber iniciado sesión. ModData.transmit desde un cliente llega al servidor y el servidor lo reenvía a todos. Cualquier jugador puede mandarte datos falsos: tu servidor valida todo lo que recibe (módulo 12).
  • OnReceiveGlobalModData no guarda nada solo. Si no lo guardas tú, se pierde.
  • El complete de una acción corre solo donde !GameClient.client: ahí va lo que cambia el mundo.

5.5 Rendimiento

Saber desde dónde y cuántas veces se dispara un evento te dice cuánto cuesta escucharlo. OnPlayerGetDamage sale de 9 lugares (enfermedad, sangrado, caídas, fuego, golpes, vehículos): si solo te importan los golpes, filtra por tipo en la primera línea. Y el aviso SlowLuaEvents del modo debug salta con callbacks de más de 250 ms: ya sabes dónde mirar.

5.6 Sobrescribir con seguridad

Cuando reemplazas algo del juego (módulo 09), el .jar te dice qué estás apagando. TriggerHook devuelve true apenas hay un callback registrado, y Java se salta su propia lógica: si registras un gancho, eres responsable de todo lo que hacía Java ahí. Leer ese método antes te evita romper una mecánica sin darte cuenta.

5.7 Respuestas con certeza

Cuando alguien comenta en Steam "tu ítem nunca aparece" o "¿esto funciona en multijugador?", puedes responder con el dato y no con una suposición. Y tus descripciones pueden decir cosas exactas ("aparece en refrigeradores con un 4 % por tirada") en vez de "es raro".

Lo que no te da

  • No es para parchear el juego. Los mods del Workshop son Lua, scripts y recursos. El .jar es para saber, no para cambiar.
  • No reemplaza probar. Te da la hipótesis correcta; el juego la confirma (paso 5 y 7 del método del módulo 15).
  • Vale para tu versión. Cada dato es de la versión que indexaste. Vuelve a comprobarlo después de una actualización.

Ejercicios

Ejercicio 8b.1 · Las opciones de sandbox del juego

Escribe un script que lea el bytecode del constructor de zombie.SandboxOptions y genere una tabla con cada opción: nombre, tipo, mínimo, máximo y valor por defecto. Pista: para cada invokevirtual newXxxOption, los argumentos son las constantes que se empujaron justo antes (ldc, iconst_N, bipush, sipush, ldc2_w para los double).

Listo cuando: tu tabla tiene 183 filas y LevelForDismantleXPCutoff sale con 0, 10 y 0.

Ejercicio 8b.2 · Un switch de texto

En ItemPickerJava$KeyNamer.getName, saca los tres textos del switch y explica qué hace el método con cada uno. Después decompila la clase y compara.

Ejercicio 8b.3 · Un evento que no existe

  1. Registra en CursoLab un Events.OnRainStart.Add(...) y un Events.OnRainStop.Add(...) que impriman en la consola.
  2. Usa el menú de debug para hacer llover y parar la lluvia.
  3. ¿Cuál se imprime? Compáralo con lo que dice pzxref.py events OnRainStart.
  4. Diseña cómo detectar el inicio de la lluvia sin ese evento (pista: busca con pzxref str qué clase maneja la lluvia y qué expone a Lua).

Ejercicio 8b.4 · Todas las fuentes de daño

Con pzxref.py events OnPlayerGetDamage, abre cada uno de los 9 lugares y anota qué argumentos pasa cada uno al evento (el tipo de daño suele ser un texto: búscalo con javap -c justo antes de la llamada). Con eso, escribe un OnPlayerGetDamage en CursoLab que anote en la bitácora (6.2) el tipo y la cantidad de cada daño.

Ejercicio 8b.5 · Los permisos de red

Saca la tabla completa de paquetes y permisos (sección 3.7). Elige tres paquetes con LoginOnServer que te parezcan delicados y busca en su método de proceso del lado servidor qué validaciones hace el juego.

Ejercicio 8b.6 · Auditoría de un mod tuyo

Elige un mod tuyo publicado y revisa, con pzxref y rg:

  1. Cada Events.X.Add y Hook.X.Add: ¿ese evento se dispara de verdad? ¿desde cuántos lugares?
  2. Cada peso de loot: ¿qué probabilidad por tirada da con la fórmula?
  3. Cada sendClientCommand/sendServerCommand: ¿los valores que mandas sobreviven a TableNetworkUtils?

Listo cuando: tienes una lista de hallazgos con la evidencia de cada uno (clase.método o archivo y línea) y arreglaste al menos uno.

Desafío 8b

Extiende pzxref.py con un comando diff <base1.db> <base2.db> que liste, entre dos versiones del juego: eventos nuevos o que dejaron de dispararse, textos nuevos en Item.DoParam (claves de script nuevas) y métodos de LuaManager$GlobalObject que aparecieron o desaparecieron. Guarda la base de tu versión actual para usarla con el próximo parche.


Anterior: 08 · Leer el código de Java · Siguiente: 09 · Sobrescribir comportamiento