DEV Community

Baltasar García Perez-Schofield
Baltasar García Perez-Schofield

Posted on • Edited on

Hackeando en 8 bits (y II)

Así que quieres hackear Abu Simbel Profanation, bribón. Bueno, teniendo en cuenta que este juego salió hace más de treinta años, te lo voy a pasar.

Nos habíamos quedado con el cargador, que era algo así:

10 border 0:paper 0: ink 0: cls: clear 26000: print at 21, 0; paper 2; ink 7; "DINAMIC PRESENTA......"; flash 1; "ABU SIMBEL"
15 print at 0, 0: poke 23633 + 256 * peek 23634, 111
20 load "" screen$: load "" code: randomize usr 26100
Enter fullscreen mode Exit fullscreen mode

Si vamos a cambiar algo del juego, tendremos que hacerlo cuando ya esté cargado en memoria. Lo ideal sería hacer los cambios necesarios (vidas infinitas, por ejemplo), cuando el juego todavía no ha sido ejecutado.

¿Se puede hacer durante la ejecución del juego? Sí, se puede hacer. Si estás ejecutando el juego en un emulador, puedes pararlo cuando quieras y realizar los cambios que desees. Si lo haces en el hardware real, como hace años, necesitarías tener un periférico que permitiera detener la ejecución del juego y cambiar valores en la memoria. Por ejemplo, serviría uno llamado Transtape.

¿Cuándo se carga el juego? Estamos hablando, al margen de la pantalla de carga (una imagen bonita con la que entretenerse mientras carga el juego), de la segunda instrucción de la línea 20: load "" code. ¿Cuándo se ejecuta el juego? En la última instrucción de la línea 20: randomize usr 26100.

Así, si queremos cambiar el juego tendrá que hacerse entre estas dos líneas. Para cambiar valores en memoria, solo tenemos disponible la instrucción poke, que recibe dos parámetros: la dirección a cambiar, y el nuevo valor.

Podríamos modificar el cargador (tras empezar con un merge "", por ejemplo), para dejarlo de la siguiente forma:

10 border 0:paper 0: ink 0: cls: clear 26000: print at 21, 0; paper 2; ink 7; "DINAMIC PRESENTA......"; flash 1; "ABU SIMBEL"
15 print at 0, 0: poke 23633 + 256 * peek 23634, 111
20 load "" screen$
30 load "" code
40 poke <dir>, <valor>
50 randomize usr 26100
Enter fullscreen mode Exit fullscreen mode

La idea es que en la línea 40 coloquemos los pokes que deseemos. De acuerdo, pero qué cambiar (o mejor dicho, dónde en la memoria del Speccy), y por qué valor.

Para saber qué podemos cambiar, no nos queda más remedio que saber algo de código máquina del Z80. El juego, al fin y al cabo, se ejecuta directamente así (un lenguaje de programación interpretado como BASIC haría el resultado demasiado lento). En varias entrevistas, el programador de Dinamic Víctor Ruíz ha declarado que utilizaba un compilador de BASIC que un conocido les había hecho, pero al ser un compilador, el resultado es el mismo: se ejecuta como código máquina.

En esta tabla de instrucciones del Z80, podemos encontrar todas las instrucciones que tiene el Z80 y cómo se codifican en memoria.

Por ejemplo, en algún momento (una vez que el jugador sea alcanzado por un malote, por ejemplo), será necesario restar uno del número de vidas del jugador. Dado que el número de vidas es menor que 255, un solo byte es suficiente para almacenarlo. Así, el juego tendría que hacer algo como lo que sigue:

ld a, ($8500)         ; Carga el contenido de $8500 en A
dec a                 ; Resta 1 a A.
ld $8500, a           ; Carga el contenido de A en $8500.
Enter fullscreen mode Exit fullscreen mode

Todos los microprocesadores tienen registros. Estos registros son los que le permiten hacer cálculos antes de guardar los resultados en memoria. En el caso del Z80, los registros genéricos son A, B, C, D, E, H y L. El caso del primer registro, A, es especial, ya que se conoce como acumulador. Este el registro utilizado por defecto en muchos opcodes, como por ejemplo en la instrucción sub 1 de más arriba.

Es probable que el código que te quita una vida esté en una subrutina, de forma que se la llama cada vez que se necesite esta funcionalidad. Esto se hace con los opcodes call y ret. La primera permite cambiar el flujo de ejecución a una subrutina, mientras que la segunda permite "volver" una vez que la ejecución de la subrutina ha terminado.

    ...
    call quita_una_vida
    ...
Enter fullscreen mode Exit fullscreen mode

La subrutina tendría como nombre esta etiqueta. Por supuesto, esto solo significa que sería una dirección de memoria que se conoce una vez el código sea compilado.

quita_una_vida:
    ld a, ($8500)         ; Carga el contenido de $8500 en A
    dec a                 ; Resta 1 a A.
    ld $8500, a           ; Carga el contenido de A en $8500.
    ret                   ; Terminar y volver.
Enter fullscreen mode Exit fullscreen mode

Todos los opcodes, finalmente, no son más que números en memoria. El código que compartido está en ensamblador, un lenguaje que puede traducirse directamente en código máquina. Por ejemplo, la subrutina más arriba se traduciría como:

; opcode                  | hex        | dec
    ld a, ($8500)         ; 3A $8500     58 85 33
    dec a                 ; 3D           61
    ld $8500, a           ; 32 $8500     50 85 33
    ret                   ; C9           201
Enter fullscreen mode Exit fullscreen mode

Así, si visualizáramos la memoria en el lugar donde ha sido ensamblada esta subrutina quita_1_vida, veríamos la secuencia 58 85 33 61 50 85 33 201.

Es importante tener en cuenta que hay instrucciones que ocupan una posición en memoria, mientras que otras ocupan, por ejemplo 3. Así, dec 1 ocupa solo una posición de memoria (restar uno es suficientemente común como para que le dedicasen un opcode dedicado), mientras que ld a, ($8500) ocupa tres: uno para el código de instrucción que carga en el acumulador el contenido de una posición de memoria, y en las siguientes dos posiciones la dirección de memoria objetivo.

Existe además (en todos los microprocesadores), una instrucción especial que se conoce como nop, que se ensambla al código 0 y que no hace nada.

Así que si tuviéramos este código en memoria, podríamos cambiarlo como:

; opcode                  | hex        | dec
    nop                   ; era: ld a, $(8500); 58 85 33
    nop
    nop                   ; 00 00 00      0  0  0
    nop                   ; 00            0; era 3D
    nop                   ; era: ld $8500, a; 50 85 33
    nop
    nop                   ; 00 00 00      0  0  0
    ret                   ; C9           201
Enter fullscreen mode Exit fullscreen mode

Y es que una vez esté el juego en memoria, no podemos cambiar una subrutina de manera que cambie su tamaño en memoria, solo podemos sobreescribirlo con código del mismo tamaño. Así, ahora el código visto en memoria sería: 0 0 0 0 0 0 0 201.

Lo que hemos conseguido es que no se ejecute nunca más el código para restar una vida.

Si lo pensamos detenidamente, sobreescribir toda la subrutina con ceros es bastante farragoso... en realidad, lo que querríamos hacer es que no se restase uno. Esto podemos hacerlo así:

; opcode                  | hex        | dec
    ld a, ($8500)         ; 3A $8500     58 85 33
    nop                   ; 00           0
    ld $8500, a           ; 32 $8500     50 85 33
    ret                   ; C9           201
Enter fullscreen mode Exit fullscreen mode

Entonces, en memoria veríamos la secuencia 58 85 33 0 50 85 33 201. Solo ha sido necesario modificar una instrucción en memoria, una instrucción que solo ocupa exactamente una posición.

Otra posibilidad es tener en cuenta que cuando se invoque la subrutina se ejecutará la primera instrucción (ld ($8500), a), por lo que, en lugar de sobreescribir memoria, podemos "adelantar" el código ret para que vuelva enseguida, sin ejecutar ningún código.

; opcode                  | hex        | dec
    ret                   ; C9           201
    ld d, 1               ; 55           85
    ld hl, nn             ; 21           33      
    dec a                 ; 3D           61
    ld $8500, a           ; 32 $8500     50 85 33
    ret                   ; C9           201
Enter fullscreen mode Exit fullscreen mode

Al cambiar la primera instrucción, el resto de posiciones en memoria serían intepretadas de distinta forma. Así, ld a, ($8500) que se traducía como 58 85 33, es cambiada por 201 85 33, con lo que 85 se reinterpreta como ld d, 1 que es el código hexadecimal 55. La siguiente instrucción se reinterpretaría como ld hl, ..., que ocupa tres posiciones de memoria. Así, el resto del programa se corrompería, pero no importa por que lo primero que se va a hacer al comenzar la subrutina es... volver. Volver sin ejecutar nada.

Otra posibilidad de modificación es buscar los lugares del código donde se hace alguna comprobación para realizar un salto en la memoria. Si transformarmos ese salto condicional (salta a la dirección nn si el jugador no ha sido alcanzado por un enemigo), en un salto incondicional (salta siempre a la dirección nn), entonces habremos logrado la inmunidad del jugador (otra "ventaja" muy buscada). Aunque hay varios saltos condicionales, jp nz, nn (siendo nn la dirección de memoria), salta cuando una condición no ha sido satisfecha. El código ensamblador es C2 (194 decimal), mientras que el del salto incondicional es C3 (195 decimal). Por lo tanto, con solo cambiar una posición de memoria (aquella donde hay un 194, por un 195), de nuevo podemos cambiar el significado del programa.

Hemos visto cómo modificar subrutinas en memoria, tanto con opcodes nop como con opcodes ret. Teniendo en cuenta que desde BASIC podemos cambiar posiciones de memoria con poke, esto explica por qué es tan común ver pokes que modifican una posición de memoria dada con un 0, un 195, o con un 201, que son las típicas en las que se obtenían ventajas como vidas infinitas o inmunidad. Claro que, para llegar a obtener estos pokes era necesario estudiar el código del juego en profundidad. Esto explica que los pokes de Abu Simbel Profanation contengan un poke 47684,0 para vidas infinitas, o un poke 45295,195 para lograr la inmunidad.

10 border 0:paper 0: ink 0: cls: clear 26000: print at 21, 0; paper 2; ink 7; "DINAMIC PRESENTA......"; flash 1; "ABU SIMBEL"
15 print at 0, 0: poke 23633 + 256 * peek 23634, 111
20 load "" screen$
30 load "" code
40 poke 45295, 195
50 randomize usr 26100
Enter fullscreen mode Exit fullscreen mode

Otra posibilidad es no otorgar vidas infinitas, sino otorgar un número de vidas tan grande que sean virtualmente infinitas. Dado que una posición en memoria es capaz de guardar como mucho el valor 255, es típico cambiar una dirección de memoria con 255 para cambiar las típicas tres o cinco vidas iniciales del juego en 255, que pueden aumentar significativamente nuestras posibilidades de terminarlo.

Pero esto lo veremos en el siguiente capítulo. ¿Y tú, también pokeabas en los 80?

Top comments (0)