Zum Inhalt

Guía Completa de Manipulación de Bytecode con ASM

Esta guía cubre en profundidad la manipulación de bytecode Java usando ASM en el contexto de plugins de Hytale. Aprenderás las instrucciones JVM, cómo usar los visitors de ASM, y técnicas avanzadas paso a paso.

Introducción al Bytecode

El bytecode es el lenguaje intermedio que ejecuta la JVM. Cada archivo .class contiene bytecode que representa las instrucciones de bajo nivel de tu código Java.

¿Por Qué Manipular Bytecode?

  • Modificar comportamiento sin código fuente: Cambiar clases del servidor de Hytale
  • Inyectar funcionalidad: Agregar hooks, logging, profiling
  • Optimizar rendimiento: Eliminar código innecesario
  • Implementar aspectos: Cross-cutting concerns (logging, seguridad, etc.)

Fundamentos de ASM

ASM es una librería de manipulación de bytecode que ofrece APIs de alto y bajo nivel.

Arquitectura de ASM

graph TD
    A[ClassReader] --> B[ClassVisitor]
    B --> C[MethodVisitor]
    B --> D[FieldVisitor]
    B --> E[AnnotationVisitor]
    C --> F[ClassWriter]
    D --> F
    E --> F
    F --> G[Bytecode Modificado]

Componentes Principales

// 1. ClassReader - Lee bytecode existente
ClassReader reader = new ClassReader(bytecode);

// 2. ClassWriter - Escribe bytecode nuevo/modificado
ClassWriter writer = new ClassWriter(
    reader,
    ClassWriter.COMPUTE_FRAMES  // Calcula stack frames automáticamente
);

// 3. ClassVisitor - Visita elementos de la clase
ClassVisitor visitor = new ClassVisitor(Opcodes.ASM9, writer) {
    @Override
    public MethodVisitor visitMethod(int access, String name,
                                     String descriptor, String signature,
                                     String[] exceptions) {
        // Tu lógica aquí
        return super.visitMethod(access, name, descriptor, signature, exceptions);
    }
};

// 4. Procesar
reader.accept(visitor, 0);
byte[] modified = writer.toByteArray();

Instrucciones JVM

Stack-Based Machine

La JVM es una máquina basada en pila (stack). Todas las operaciones trabajan con valores en el stack.

// Código Java:
int result = a + b;

// Bytecode equivalente:
ILOAD 1      // Cargar variable 'a' al stack
ILOAD 2      // Cargar variable 'b' al stack
IADD         // Sumar los dos valores del stack
ISTORE 3     // Guardar resultado en variable 'result'

Instrucciones de Carga (LOAD)

Cargan valores al stack desde variables locales.

Instrucción Tipo Descripción
ALOAD Reference Cargar referencia de objeto
ILOAD int Cargar entero
LLOAD long Cargar long (64-bit)
FLOAD float Cargar float
DLOAD double Cargar double (64-bit)
// Ejemplos de uso
mv.visitVarInsn(Opcodes.ALOAD, 0);  // Cargar 'this'
mv.visitVarInsn(Opcodes.ILOAD, 1);  // Cargar primer parámetro int
mv.visitVarInsn(Opcodes.LLOAD, 2);  // Cargar segundo parámetro long

Instrucciones de Almacenamiento (STORE)

Guardan valores del stack en variables locales.

Instrucción Tipo Descripción
ASTORE Reference Guardar referencia
ISTORE int Guardar entero
LSTORE long Guardar long
FSTORE float Guardar float
DSTORE double Guardar double
// Ejemplos
mv.visitVarInsn(Opcodes.ISTORE, 3);  // Guardar int en variable local 3
mv.visitVarInsn(Opcodes.ASTORE, 4);  // Guardar objeto en variable local 4

Instrucciones de Invocación (INVOKE)

Llaman a métodos.

Instrucción Uso
INVOKEVIRTUAL Métodos de instancia (normales)
INVOKESPECIAL Constructores, métodos privados, super
INVOKESTATIC Métodos estáticos
INVOKEINTERFACE Métodos de interfaces
// INVOKEVIRTUAL - Llamar método de instancia
mv.visitVarInsn(Opcodes.ALOAD, 0);  // this
mv.visitMethodInsn(
    Opcodes.INVOKEVIRTUAL,
    "com/ejemplo/MiClase",     // Owner class
    "miMetodo",                 // Method name
    "()V",                      // Descriptor (void sin parámetros)
    false                       // No es interfaz
);

// INVOKESTATIC - Llamar método estático
mv.visitMethodInsn(
    Opcodes.INVOKESTATIC,
    "java/lang/System",
    "currentTimeMillis",
    "()J",                      // Retorna long
    false
);

// INVOKESPECIAL - Llamar constructor
mv.visitTypeInsn(Opcodes.NEW, "java/lang/String");
mv.visitInsn(Opcodes.DUP);
mv.visitLdcInsn("Hello");
mv.visitMethodInsn(
    Opcodes.INVOKESPECIAL,
    "java/lang/String",
    "<init>",                   // Constructor
    "(Ljava/lang/String;)V",
    false
);

Instrucciones de Stack

Manipulan directamente el stack.

Instrucción Descripción
DUP Duplica el valor en el tope del stack
DUP2 Duplica los dos valores en el tope
POP Descarta el valor en el tope
SWAP Intercambia los dos valores en el tope
// DUP - Duplicar valor
mv.visitVarInsn(Opcodes.ALOAD, 0);
mv.visitInsn(Opcodes.DUP);  // Ahora tenemos 2 copias en el stack

// SWAP - Intercambiar valores
mv.visitLdcInsn("Hello");     // Stack: ["Hello"]
mv.visitLdcInsn("World");     // Stack: ["Hello", "World"]
mv.visitInsn(Opcodes.SWAP);   // Stack: ["World", "Hello"]

Instrucciones Aritméticas

Instrucción Operación
IADD Sumar enteros
ISUB Restar enteros
IMUL Multiplicar enteros
IDIV Dividir enteros
IREM Módulo (residuo)
INEG Negar (cambiar signo)
// Ejemplo: int c = a + b;
mv.visitVarInsn(Opcodes.ILOAD, 1);  // Cargar a
mv.visitVarInsn(Opcodes.ILOAD, 2);  // Cargar b
mv.visitInsn(Opcodes.IADD);         // Sumar
mv.visitVarInsn(Opcodes.ISTORE, 3); // Guardar en c

Instrucciones de Campos

// GETSTATIC - Obtener campo estático
mv.visitFieldInsn(
    Opcodes.GETSTATIC,
    "java/lang/System",      // Owner
    "out",                   // Field name
    "Ljava/io/PrintStream;"  // Type descriptor
);

// GETFIELD - Obtener campo de instancia
mv.visitVarInsn(Opcodes.ALOAD, 0);  // Cargar objeto
mv.visitFieldInsn(
    Opcodes.GETFIELD,
    "com/ejemplo/Player",
    "health",
    "I"                      // int
);

// PUTSTATIC - Establecer campo estático
mv.visitLdcInsn(100);
mv.visitFieldInsn(
    Opcodes.PUTSTATIC,
    "com/ejemplo/Config",
    "maxPlayers",
    "I"
);

// PUTFIELD - Establecer campo de instancia
mv.visitVarInsn(Opcodes.ALOAD, 0);
mv.visitLdcInsn(100);
mv.visitFieldInsn(
    Opcodes.PUTFIELD,
    "com/ejemplo/Player",
    "health",
    "I"
);

Instrucciones de Control de Flujo

// IF - Condicionales
Label elseLabel = new Label();
Label endLabel = new Label();

mv.visitVarInsn(Opcodes.ILOAD, 1);  // Cargar variable
mv.visitInsn(Opcodes.ICONST_0);      // Cargar 0
mv.visitJumpInsn(Opcodes.IF_ICMPLE, elseLabel);  // if (var <= 0) goto else

// Código del if
mv.visitLdcInsn("Positivo");
mv.visitJumpInsn(Opcodes.GOTO, endLabel);

// Código del else
mv.visitLabel(elseLabel);
mv.visitLdcInsn("No positivo");

mv.visitLabel(endLabel);

Descriptores de Tipo

Los descriptores representan tipos en bytecode.

Tipo Java Descriptor
void V
int I
long J
float F
double D
boolean Z
byte B
char C
short S
Object Ljava/lang/Object;
String Ljava/lang/String;
int[] [I
Object[] [Ljava/lang/Object;

Descriptores de Método:

// void method()
"()V"

// int method(String s)
"(Ljava/lang/String;)I"

// String method(int a, boolean b, Object c)
"(IZLjava/lang/Object;)Ljava/lang/String;"

// void method(int[] array)
"([I)V"

MethodVisitor en Profundidad

Ciclo de Vida de un MethodVisitor

public class ExampleMethodVisitor extends MethodVisitor {

    public ExampleMethodVisitor(int api, MethodVisitor mv) {
        super(api, mv);
    }

    // 1. Inicio del método
    @Override
    public void visitCode() {
        super.visitCode();
        // Código al inicio del método
    }

    // 2. Instrucciones simples (sin operandos)
    @Override
    public void visitInsn(int opcode) {
        // RETURN, ARETURN, IADD, DUP, etc.
        super.visitInsn(opcode);
    }

    // 3. Instrucciones de variables locales
    @Override
    public void visitVarInsn(int opcode, int var) {
        // ALOAD, ILOAD, ASTORE, ISTORE, etc.
        super.visitVarInsn(opcode, var);
    }

    // 4. Instrucciones de campos
    @Override
    public void visitFieldInsn(int opcode, String owner,
                               String name, String descriptor) {
        // GETFIELD, PUTFIELD, GETSTATIC, PUTSTATIC
        super.visitFieldInsn(opcode, owner, name, descriptor);
    }

    // 5. Instrucciones de métodos
    @Override
    public void visitMethodInsn(int opcode, String owner, String name,
                               String descriptor, boolean isInterface) {
        // INVOKEVIRTUAL, INVOKESTATIC, etc.
        super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);
    }

    // 6. Instrucciones de saltos (if, goto)
    @Override
    public void visitJumpInsn(int opcode, Label label) {
        // IF_ICMPEQ, GOTO, etc.
        super.visitJumpInsn(opcode, label);
    }

    // 7. Labels (marcadores de posición)
    @Override
    public void visitLabel(Label label) {
        super.visitLabel(label);
    }

    // 8. Constantes
    @Override
    public void visitLdcInsn(Object value) {
        // LDC - cargar constante
        super.visitLdcInsn(value);
    }

    // 9. Fin del método
    @Override
    public void visitMaxs(int maxStack, int maxLocals) {
        super.visitMaxs(maxStack, maxLocals);
    }

    @Override
    public void visitEnd() {
        super.visitEnd();
    }
}

Ejemplos Paso a Paso

Ejemplo 1: Inyectar Código al Inicio de Métodos

package com.ejemplo.bytecode.examples;

import org.objectweb.asm.*;

/**
 * Inyecta System.out.println al inicio de todos los métodos.
 */
public class InjectAtStartExample extends MethodVisitor {

    private final String className;
    private final String methodName;

    public InjectAtStartExample(int api, MethodVisitor mv,
                               String className, String methodName) {
        super(api, mv);
        this.className = className;
        this.methodName = methodName;
    }

    @Override
    public void visitCode() {
        // Llamar al original primero
        super.visitCode();

        // Inyectar al inicio:
        // System.out.println("Entering: ClassName.methodName");

        // 1. Obtener System.out
        mv.visitFieldInsn(
            Opcodes.GETSTATIC,
            "java/lang/System",
            "out",
            "Ljava/io/PrintStream;"
        );

        // 2. Preparar mensaje
        mv.visitLdcInsn("Entering: " + className + "." + methodName);

        // 3. Llamar println
        mv.visitMethodInsn(
            Opcodes.INVOKEVIRTUAL,
            "java/io/PrintStream",
            "println",
            "(Ljava/lang/String;)V",
            false
        );

        System.out.println("[Inject] Added logging to " + methodName);
    }
}

Ejemplo 2: Modificar Valores de Retorno

package com.ejemplo.bytecode.examples;

import org.objectweb.asm.*;

/**
 * Modifica valores de retorno de métodos.
 * Ejemplo: Siempre retornar true en métodos boolean.
 */
public class ModifyReturnExample extends MethodVisitor {

    private final String returnType;

    public ModifyReturnExample(int api, MethodVisitor mv, String descriptor) {
        super(api, mv);
        this.returnType = Type.getReturnType(descriptor).getDescriptor();
    }

    @Override
    public void visitInsn(int opcode) {
        // Interceptar instrucciones de retorno
        if (opcode == Opcodes.IRETURN && returnType.equals("Z")) {
            // Es un retorno de boolean

            // Descartar valor original
            mv.visitInsn(Opcodes.POP);

            // Cargar true (1)
            mv.visitInsn(Opcodes.ICONST_1);

            System.out.println("[ModifyReturn] Forcing return value to true");
        }

        super.visitInsn(opcode);
    }
}

Ejemplo 3: Agregar Try-Catch

package com.ejemplo.bytecode.examples;

import org.objectweb.asm.*;

/**
 * Envuelve todo el método en try-catch.
 */
public class AddTryCatchExample extends MethodVisitor {

    private final Label startLabel = new Label();
    private final Label endLabel = new Label();
    private final Label handlerLabel = new Label();

    public AddTryCatchExample(int api, MethodVisitor mv) {
        super(api, mv);
    }

    @Override
    public void visitCode() {
        super.visitCode();

        // Registrar try-catch block
        mv.visitTryCatchBlock(
            startLabel,
            endLabel,
            handlerLabel,
            "java/lang/Exception"  // Tipo de excepción a capturar
        );

        // Inicio del try
        mv.visitLabel(startLabel);
    }

    @Override
    public void visitInsn(int opcode) {
        // Antes de cada RETURN, marcar fin del try
        if (opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN) {
            mv.visitLabel(endLabel);
        }

        super.visitInsn(opcode);

        // Después del último RETURN, agregar handler
        if (opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN) {
            addExceptionHandler();
        }
    }

    private void addExceptionHandler() {
        // Handler del catch
        mv.visitLabel(handlerLabel);

        // Guardar excepción en variable local
        mv.visitVarInsn(Opcodes.ASTORE, 1);

        // System.err.println(exception.getMessage())
        mv.visitFieldInsn(
            Opcodes.GETSTATIC,
            "java/lang/System",
            "err",
            "Ljava/io/PrintStream;"
        );

        mv.visitLdcInsn("Exception caught: ");
        mv.visitMethodInsn(
            Opcodes.INVOKEVIRTUAL,
            "java/io/PrintStream",
            "print",
            "(Ljava/lang/String;)V",
            false
        );

        mv.visitFieldInsn(
            Opcodes.GETSTATIC,
            "java/lang/System",
            "err",
            "Ljava/io/PrintStream;"
        );

        mv.visitVarInsn(Opcodes.ALOAD, 1);
        mv.visitMethodInsn(
            Opcodes.INVOKEVIRTUAL,
            "java/lang/Exception",
            "getMessage",
            "()Ljava/lang/String;",
            false
        );

        mv.visitMethodInsn(
            Opcodes.INVOKEVIRTUAL,
            "java/io/PrintStream",
            "println",
            "(Ljava/lang/String;)V",
            false
        );

        // Retornar (ajustar según tipo de retorno)
        mv.visitInsn(Opcodes.RETURN);
    }
}

Ejemplo 4: Interceptar Llamadas a Métodos

package com.ejemplo.bytecode.examples;

import org.objectweb.asm.*;

/**
 * Intercepta llamadas a métodos específicos y agrega logging.
 */
public class InterceptMethodCallsExample extends MethodVisitor {

    private static final String TARGET_CLASS = "com/hypixel/hytale/server/core/entity/entities/Player";
    private static final String TARGET_METHOD = "takeDamage";

    public InterceptMethodCallsExample(int api, MethodVisitor mv) {
        super(api, mv);
    }

    @Override
    public void visitMethodInsn(int opcode, String owner, String name,
                               String descriptor, boolean isInterface) {

        if (owner.equals(TARGET_CLASS) && name.equals(TARGET_METHOD)) {
            // Interceptado!

            // Log antes de la llamada
            logBeforeCall(owner, name);

            // Llamada original
            super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);

            // Log después de la llamada
            logAfterCall(owner, name);

        } else {
            // No interceptar
            super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);
        }
    }

    private void logBeforeCall(String owner, String name) {
        mv.visitFieldInsn(
            Opcodes.GETSTATIC,
            "java/lang/System",
            "out",
            "Ljava/io/PrintStream;"
        );

        mv.visitLdcInsn("[Intercept] Calling " + owner + "." + name);

        mv.visitMethodInsn(
            Opcodes.INVOKEVIRTUAL,
            "java/io/PrintStream",
            "println",
            "(Ljava/lang/String;)V",
            false
        );
    }

    private void logAfterCall(String owner, String name) {
        mv.visitFieldInsn(
            Opcodes.GETSTATIC,
            "java/lang/System",
            "out",
            "Ljava/io/PrintStream;"
        );

        mv.visitLdcInsn("[Intercept] Returned from " + owner + "." + name);

        mv.visitMethodInsn(
            Opcodes.INVOKEVIRTUAL,
            "java/io/PrintStream",
            "println",
            "(Ljava/lang/String;)V",
            false
        );
    }
}

Stack Frames y Compute Frames

¿Qué son los Stack Frames?

Los stack frames definen el estado del stack y variables locales en cada punto del método. Son esenciales para la verificación de bytecode.

Compute Frames Automático

ClassWriter writer = new ClassWriter(
    reader,
    ClassWriter.COMPUTE_FRAMES  // ASM calcula frames automáticamente
);

Recomendación

Siempre usa COMPUTE_FRAMES cuando modifiques el flujo de control (saltos, try-catch, etc.). ASM calculará los frames correctamente.

Compute Frames Manual

ClassWriter writer = new ClassWriter(
    reader,
    ClassWriter.COMPUTE_MAXS  // Solo calcula maxs, frames manuales
);

// Debes especificar frames manualmente
mv.visitFrame(
    Opcodes.F_SAME,  // Tipo de frame
    0, null,         // Locals
    0, null          // Stack
);

Advertencia

El cálculo manual de frames es complejo y propenso a errores. Usa COMPUTE_FRAMES a menos que tengas una razón específica.

Debugging de Bytecode

1. Usar ASM CheckClassAdapter

Verifica que el bytecode generado sea válido.

import org.objectweb.asm.util.*;

public byte[] transform(byte[] bytecode) {
    ClassReader reader = new ClassReader(bytecode);
    ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_FRAMES);

    ClassVisitor visitor = new MyTransformer(Opcodes.ASM9, writer);
    reader.accept(visitor, 0);

    byte[] transformed = writer.toByteArray();

    // Verificar bytecode
    try {
        ClassReader verifier = new ClassReader(transformed);
        ClassVisitor checker = new CheckClassAdapter(
            new ClassWriter(0),
            true  // Verificación completa
        );
        verifier.accept(checker, 0);
        System.out.println("[Verify] Bytecode is valid!");
    } catch (Exception e) {
        System.err.println("[Verify] Invalid bytecode: " + e.getMessage());
        e.printStackTrace();
    }

    return transformed;
}

2. Imprimir Bytecode Generado

import org.objectweb.asm.util.*;
import java.io.*;

public void printBytecode(byte[] bytecode) {
    ClassReader reader = new ClassReader(bytecode);
    TraceClassVisitor tracer = new TraceClassVisitor(
        new PrintWriter(System.out)
    );
    reader.accept(tracer, 0);
}

3. Comparar Bytecode

import org.objectweb.asm.util.*;

public void compareBytecode(byte[] original, byte[] transformed) {
    System.out.println("=== ORIGINAL ===");
    printBytecode(original);

    System.out.println("\n=== TRANSFORMED ===");
    printBytecode(transformed);
}

4. Usar javap

# Descompilar .class
javap -c -p MyClass.class

# Más detallado
javap -c -p -v MyClass.class

# Guardar en archivo
javap -c -p MyClass.class > MyClass.bytecode.txt

ClassVisitor y FieldVisitor

ClassVisitor Completo

package com.ejemplo.bytecode;

import org.objectweb.asm.*;

public class CompleteClassVisitor extends ClassVisitor {

    public CompleteClassVisitor(int api, ClassVisitor cv) {
        super(api, cv);
    }

    @Override
    public void visit(int version, int access, String name,
                     String signature, String superName, String[] interfaces) {
        System.out.println("Visiting class: " + name);
        System.out.println("  Extends: " + superName);
        System.out.println("  Implements: " + String.join(", ", interfaces));
        super.visit(version, access, name, signature, superName, interfaces);
    }

    @Override
    public FieldVisitor visitField(int access, String name, String descriptor,
                                   String signature, Object value) {
        System.out.println("Found field: " + name + " : " + descriptor);
        return super.visitField(access, name, descriptor, signature, value);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor,
                                     String signature, String[] exceptions) {
        System.out.println("Found method: " + name + descriptor);
        return super.visitMethod(access, name, descriptor, signature, exceptions);
    }

    @Override
    public void visitEnd() {
        System.out.println("Finished visiting class");
        super.visitEnd();
    }
}

FieldVisitor para Modificar Campos

package com.ejemplo.bytecode;

import org.objectweb.asm.*;

public class FieldModifierVisitor extends ClassVisitor {

    public FieldModifierVisitor(int api, ClassVisitor cv) {
        super(api, cv);
    }

    @Override
    public FieldVisitor visitField(int access, String name, String descriptor,
                                   String signature, Object value) {

        // Hacer todos los campos públicos
        int newAccess = (access & ~Opcodes.ACC_PRIVATE & ~Opcodes.ACC_PROTECTED)
                       | Opcodes.ACC_PUBLIC;

        System.out.println("[FieldModifier] Changed " + name + " to public");

        return super.visitField(newAccess, name, descriptor, signature, value);
    }

    @Override
    public void visitEnd() {
        // Agregar nuevo campo
        FieldVisitor fv = cv.visitField(
            Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC | Opcodes.ACC_FINAL,
            "MODIFIED_BY_PLUGIN",
            "Z",  // boolean
            null,
            true  // valor = true
        );
        fv.visitEnd();

        super.visitEnd();
    }
}

Mejores Prácticas

1. Siempre Verificar Bytecode

// Usar CheckClassAdapter en desarrollo
if (DEBUG_MODE) {
    verifyBytecode(transformedBytes);
}

2. Manejar Excepciones Apropiadamente

@Override
public byte[] transform(String className, String classPath, byte[] bytecode) {
    try {
        return doTransform(bytecode);
    } catch (Exception e) {
        System.err.println("[Transform] Error in " + className + ": " + e.getMessage());
        e.printStackTrace();
        return null;  // No modificar si hay error
    }
}

3. Considerar Compatibilidad de Versiones

// Verificar versión de bytecode
@Override
public void visit(int version, int access, String name,
                 String signature, String superName, String[] interfaces) {
    if (version < Opcodes.V17) {
        System.out.println("[Warning] Old bytecode version: " + version);
    }
    super.visit(version, access, name, signature, superName, interfaces);
}

4. Documentar Transformaciones

/**
 * Transforma la clase Player para agregar:
 * - Logging en takeDamage()
 * - Campo personalizado: customHealth
 * - Método getter: getCustomHealth()
 *
 * @param bytecode Bytecode original
 * @return Bytecode transformado
 */
public byte[] transformPlayer(byte[] bytecode) {
    // ...
}

Recursos Adicionales


¿Problemas? Consulta Troubleshooting