Comunicación Serie en el Control de la NES

Hoy me ha dado ganas de escribir un poco sobre la NES, y en este caso en especifico quiero explicar un poco a bajo nivel como funciona el control de la nes, un poco a nivel de electronica (alto nivel), como a nivel de software (bajo nivel).

Que vamos a realizar?

El programa que tengo en mente debe realizar lo siguiente:

  1. Leer la entrada del control 1 bit a bit
  2. Guardar la salida en la posición de memoria 0x00
  3. Leer la entrada del control 2 bit a bit
  4. Guardar la salida en la posición de memoria 0x01
  5. Compilar la rom

La idea es poder revisar el estado de los botones que tenemos pulsando utilizando el debugger del emulador FCEUX y leer la posición de memoria 0x00 para el control 1 y 0x01 para el control 2.

Estructura del control en memoria

La siguiente tabla es una representación de como guardaremos los datos en memoria.

Bit 7 Bit 6 Bit 5 Bit 4 Bit 3 Bit 2 Bit 1 Bit 0
A B Select Start Up Down Left Right

Que necesitamos conocer antes de programar?

Antes que nada necesitamos conocer un poco el hardware de la nes, de igual forma intentare escribirlo a muy alto nivel, utilizando nombres claves por si desean investigar puedan realizar la investigación por su cuenta y asi este post quede lo mas simple posible.

El shift register

El shift register utilizado en el control de la NES es el 4021. Este es un registro de desplazamiento de 8 bits con capacidad de conversión de datos de paralelo a serie. Esto significa que puede almacenar simultáneamente ocho valores de entrada y posteriormente enviarlos de forma secuencial, un bit a la vez. Debido a que dispone de 8 bits, cada botón del control se representa mediante un bit dentro del registro.

Para leer el estado de los botones, el procesador de la consola (el Ricoh 2A03) interactúa con los mandos a través de sus puertos de entrada/salida, mapeados en las direcciones de memoria $4016 (JOY1) para el control 1 y $4017 (JOY2) para el control 2.

A bajo nivel, el pulso se genera mediante la propia lógica de decodificación de direcciones del procesador:

  1. Decodificación de la lectura: Cuando el 2A03 ejecuta una instrucción de lectura sobre el bus (por ejemplo, LDA $4016), su decodificador interno de direcciones detecta la combinación de la dirección $4016 y la señal de lectura del bus en alto).
  2. Generación de la señal física: Luego la lógica interna del 2A03 conmuta a nivel bajo la línea de salida (/JOY1 o /JOY2). Con esto se genera un pulso de clock directo al pin 9 (CLK) del registro de desplazamiento 4021.
  3. Desplazamiento del bit: El flanco de subida de este pulso de reloj obliga al 4021 a desplazar su registro un bit hacia adelante, colocando inmediatamente el estado del siguiente botón en la salida serial del pin 3 (Q8).
  4. Mapeo en el bus de datos: La línea serial del pin 3 entra de vuelta al 2A03 y se mapea directamente en el bit de datos $D_0$ durante ese mismo ciclo de lectura.

De esta forma, la generación del pulso de clock es un efecto secundario puramente hardware derivado de la lectura en memoria: cada vez que el código ejecuta una instrucción de lectura en $4016 o $4017, el procesador emite automáticamente un pulso de reloj, permitiendo obtener el estado completo de los 8 botones realizando 8 lecturas consecutivas.

El compilador

Para compilar el programa que vamos a crear utilizaremos el compilador cc65, no tenia pensado explicar como se compila, pero vamos es muy sencillo

git clone https://github.com/cc65/cc65.git
cd cc65
make
sudo make PREFIX=/usr/local install

El código

Dejaré el código comentado para facilitar su comprensión, pero se asume que quienes lo lean tienen conocimientos básicos de Make y ASM, por lo que no explicaré cada punto de manera detallada.

Ensamblador

main.asm

; ---------------------------------------------------------------------------
; Cabecera iNES (Formato estándar de 16 bytes)
; ---------------------------------------------------------------------------
.segment "HEADER"
    .byte 'N', 'E', 'S', $1A  ; Firma de identificación
    .byte 1                   ; 1 banco de 16KB PRG
    .byte 0                   ; 0 bancos de 8KB CHR (Usa CHR RAM)
    .byte $01                 ; Mapper 0 (NROM), Mirroring Vertical
    .byte $00, $00, $00, $00, $00, $00, $00, $00, $00 ; Padding (9 bytes)
; ---------------------------------------------------------------------------
; Variables en RAM (Zero Page)
; ---------------------------------------------------------------------------
.segment "ZEROPAGE"
    joy1:      .res 1         ; Dir $00 - Estado final estable del control 1
    joy2:      .res 1         ; Dir $01 - Estado final estable del control 2
    temp_joy1: .res 1         ; Dir $02 - Variable temporal de trabajo 1
    temp_joy2: .res 1         ; Dir $03 - Variable temporal de trabajo 2
; ---------------------------------------------------------------------------
; Código Principal
; ---------------------------------------------------------------------------
.segment "CODE"
RESET:
    sei          ; Deshabilita interrupciones
    cld          ; Deshabilita modo decimal (no soportado por NES)
    ldx #$40
    stx $4017    ; Deshabilita interrupciones del APU (Audio)
    ldx #$FF
    txs          ; Inicializa el puntero de la pila (Stack Pointer) a $FF
    inx          ; X pasa a ser 0x00
    stx $2000    ; Deshabilita NMI (Interrupciones de video)
    stx $2001    ; Apaga el renderizado de pantalla
    stx $4010    ; Deshabilita interrupciones DMC
vblankwait1:
    bit $2002
    bpl vblankwait1
vblankwait2:
    bit $2002
    bpl vblankwait2
MainLoop:
    ; 1. Latch (Captura el estado actual de ambos controles)
    lda #$01
    sta $4016    ; Envía señal de HIGH al strobe
    lda #$00
    sta $4016    ; Envía señal de LOW al strobe (captura completada)
    ; 2. Lectura secuencial de los 8 botones
    ldx #$08     ; Contador para leer 8 veces
ReadLoop:
    ; --- Leer Control 1 en la variable temporal ---
    lda $4016    ; Lee el puerto del control 1
    lsr a        ; Extrae el bit al Carry Flag
    rol temp_joy1; Inserta el bit por la derecha y desplaza a la izquierda
    ; --- Leer Control 2 en la variable temporal ---
    lda $4017    ; Lee el puerto del control 2
    lsr a        ; Extrae el bit al Carry Flag
    rol temp_joy2; Inserta el bit por la derecha y desplaza a la izquierda
    dex          ; Reduce el contador X en 1
    bne ReadLoop ; Si X no es cero, vuelve a leer el siguiente botón
    ; 3. Volcar los resultados empaquetados a las variables finales
    lda temp_joy1
    sta joy1     ; Actualiza $00 con el byte ordenado: [A|B|Sel|St|U|D|L|R]
    lda temp_joy2
    sta joy2     ; Actualiza $01 con el byte ordenado: [A|B|Sel|St|U|D|L|R]
    jmp MainLoop ; Repite el bucle infinitamente
NMI:
IRQ:
    rti          ; Retorno de interrupción
; ---------------------------------------------------------------------------
; Vectores de hardware obligatorios
; ---------------------------------------------------------------------------
.segment "VECTORS"
    .word NMI    ; Vector NMI ($FFFA)
    .word RESET  ; Vector Reset ($FFFC) - Punto de entrada
    .word IRQ    ; Vector IRQ/BRK ($FFFE)

La razón por la que no se envía la señal de alto y bajo (Latch o Strobe) al registro $4017 para el control 2 se debe a cómo está diseñado el hardware de la NES.

En la arquitectura de la consola, escribir en $4016 envía la señal de Latch a ambos puertos de control simultáneamente.

Definición de la rom de la nes

nrom.cfg

MEMORY {
    # Memoria RAM del NES
    ZP:     start = $0000, size = $0100, type = rw; # Zero Page
    RAM:    start = $0200, size = $0600, type = rw; # RAM general
    # Memoria ROM del Cartucho
    HEADER: start = $0000, size = $0010,
      type = ro, file = %O, fill = yes, fillval = $00;
    PRG:    start = $C000, size = $4000,
      type = ro, file = %O, fill = yes, fillval = $FF;
}
SEGMENTS {
    # Mapeo de los bloques lógicos del código a la memoria física
    HEADER:   load = HEADER, type = ro;
    ZEROPAGE: load = ZP,     type = zp;
    CODE:     load = PRG,    type = ro, start = $C000;
    VECTORS:  load = PRG,    type = ro, start = $FFFA;
}

Makefile

PROJECT = control
AS = ca65
LD = ld65
CFG = nrom.cfg
SRC = main.asm
OBJ = main.o
OUT = $(PROJECT).nes
# --- Reglas ---
# La regla por defecto que se ejecuta al escribir "make"
all: $(OUT)
# Cómo crear el archivo .nes (Enlazado)
$(OUT): $(OBJ)
    $(LD) -C $(CFG) -o $(OUT) $(OBJ)
# Cómo crear el archivo .o (Ensamblado)
$(OBJ): $(SRC)
    $(AS) $(SRC) -o $(OBJ)
# Regla para limpiar los archivos generados
clean:
    rm -f $(OBJ) $(OUT)
# Declaración de reglas que no son archivos reales
.PHONY: all clean

Ingeniería inversa y ROM patching en Contra (NES)

El objetivo de esta guía es analizar y modificar el comportamiento interno de la versión japonesa de Contra mediante ingeniería inversa básica. A lo largo del artículo exploramos cómo localizar variables clave en la memoria RAM (vidas y armamento), rastrear las subrutinas de la CPU 6502 que interactúan con esas direcciones y aplicar parches permanentes directamente en el binario ROM.

file: Contra (Japan).nes
md5: 0e40bc1b049c16c5d7246cc28399cb5d
sha1: 376836361f404c815d404e1d5903d5d11f4eff0e
cpu: 6502
mapper: 23 konami vrc2/vrc4 c  

nescartdb.com/profile/view/3986/contra

Para este proyecto utilizaremos unicamente, FCEUX y Radare2 y se puede realizar desde cualquier sistema operativo.

Este documento explica como se descubrieron y modificaron los valores que se muestran en la siguente tabla.

addrs offset Tipo descripción
0032 – RAM Vidas
00AA – RAM Arma
07:C2E9 1C2F9 ROM Asigna 2 vidas en ram
07:C2EF 1C2FF ROM Asigna 29 vidas en ram

Las vidas la he buscado utilizando el buscador de memoria incluido en FCEUX 2.6.6, posteriormente he agregado un breakpoint de escritura en la dirección 0x0032 para encontrar la logica que asigna las vidas.

Radare2 y NES

Quiero utilizar radare2 para parchear el juego, pero no tengo claro como realizar un cambio del banco de memoria y si realizo una busqueda en el binario no tendremos resultados si esta en otro banco. Lo mejor es trabajarlo en modo raw.

Abrimos el juego en modo raw.

r2 -a 6502 -w -n Contra\ \(Japan\).nes

Ahora vamos a buscar la siguiente instrucción 95 32 STA $32,X que luego de investigar en FCEUX sabemos que es la instrucción que utilizan para asignar la vida en la dirección 0x32.

deberia tener un resultado como el siguiente.

[0x00000000]> /x 95 32
0x0001c301 hit0_0 9532
0x0001c3b7 hit0_1 9532

disassembly la siguiente dirección, un poco antes de la que encontramos para ver donde se asigna el registro A que es el que se cargara en la dirección 0x32.

[0x00000000]> pd 10 @ 0x0001c2f0
            0x0001c2f0      03             slo (0xc3, x)
            0x0001c2f1      c3             dcp (0x85, x)
            0x0001c2f2      851d           sta 0x1d
            0x0001c2f4      bd05c3         lda 0xc305,x
            0x0001c2f7      8539           sta 0x39
            0x0001c2f9      a902           lda 0x02
            0x0001c2fb      a424           ldy 0x24
        ┌─< 0x0001c2fd      f002           beq 0x01c301
        │   0x0001c2ff      a91d           lda 0x1d
        │   ;-- hit0_0:
        └─> 0x0001c301      9532           sta 0x32,x

Aqui podemos ver donde se carga a memoria y donde se asigna el valor de las vidas a modificar, el codigo konami se valida con el beq, a nosotros nos interesa cambiar las vidas de 2 a 5 y lo realizamos con el siguente comando.

wx 05 @ 0x0001c2fa
Podemos salirnos con q en radare2 para probar nuestro cambio.

:: info
Si queremos saber la dirección que muestra radare desde FCEUX solo tenemos que colocar el cursor sobre la dirección de memoria y dira la dirección en el offset.

::

Buscando las armas por defecto

Para este proyecto decidí trabajar completamente offline, sin realizar búsquedas en internet. Como no conozco el identificador de cada arma en Contra, tuve que hacer la búsqueda manualmente, revisando los valores desde 00 hasta FF para localizar la dirección de memoria asociada al arma seleccionada y determinar su valor.

El método que me resultó más sencillo fue reemplazar una línea completa por el valor 00 e ir probando hasta identificar en qué línea se encontraba el arma, para luego localizar el byte exacto.

La siguente tabla muestra el valor correspondiente a la dirección 0xAA que corresponde al arma seleccionada.

Valor Letra Descripción
00 – Gun
01 M Machine Gun Falcon
02 F Fire Ball Falcon
03 S Spread Gun Falcon
04 L Laser Falcon

Para el Rapid Fire Falcon es un caso especial, es simplemente activar el bit de Rapid Fire, sumando 0x10 al valor base del arma seleccionada.

tabla resultante.

Valor Letra Descripción
10 – Gun
11 M Machine Gun Falcon
12 F Fire Ball Falcon
13 S Spread Gun Falcon
14 L Laser Falcon

Buscando logica para la asignación de arma al inicio de la partida

Como ya tenemos la dirección de memoria 0xAA podemos activar un breakpoint de escritura en FCEUX y ver que rutina escribe en esa dirección. Asignamos el breakpoint y realizamos un hard reset y le damos continuar al debugger hasta que presionemos el boton start para empezar el juego y ver donde se llama la logica que asigna el arma.

El primer resultado es el que buscamos y es el siguiente:

CPU -> A: 00 X: AA
07:C303     ORA ($07, X)
07:C305     ORA ($00, X)
07:C307     LDX #$28          ; Asigna X = 0x28
07:C309     LDA #$00          ; Asigna A = 0
-> 07:C30B  STA $00,X         ; RAM[X] = A
07:C30D     INX               ; Incrementa X en 1.
07:C30E     CPX #$F0          ; Compara X == 0xF0
07:C310     BNE $C30B         ; Si X no es 0xF0 salta a C30B

La logica anterior es un bucle que asigna a 0 todos los valores de la ram desde 0x28 hasta 0xf0 es decir que esta incluido la vida que es la dirección 0x32 y el arma que es la dirección 0xAA .

¿Que problemas tenemos ahora?

No podemos modificar esta subrutina porque es la encargada de reiniciar la memoria al iniciar el juego. Además, se aprovecha de que el valor del arma por defecto es 0, de manera que al inicio ya está asignada esa arma. Sin embargo, ocurre algo diferente con la vida: esta se asigna a 0, lo que significa que, después de ejecutar esta subrutina, se ejecuta otra que asigna el valor correcto de vida. Por eso, el juego no comienza con 0 vidas.

Esta subrutina ya nos resulta familiar, pues fue el primer cambio que realizamos al inicio de este documento. ¿Y si aprovechamos la asignación de vida, que se ejecuta después del reinicio de memoria, para asignar también el arma en la dirección 0xAA? Eso es precisamente lo que vamos a hacer.

veamos esta subrutina desde radare2.

[0x00000000]> pd 16 @ 0x0001c2f9
        ┌─> 0x0001c2f9      a905           lda 0x05
        ╎   0x0001c2fb      a424           ldy 0x24
       ┌──< 0x0001c2fd      f002           beq 0x01c301
       │╎   0x0001c2ff      a91d           lda 0x1d
       └──> 0x0001c301      9532           sta 0x32,x
        ╎   0x0001c303      ca             dex
        └─< 0x0001c304      10f3           bpl 0x01c2f9
            0x0001c306      a9c8           lda 0xc8
            0x0001c308      853c           sta 0x3c
            0x0001c30a      853e           sta 0x3e
            0x0001c30c      a900           lda 0x00
            0x0001c30e      853d           sta 0x3d
            0x0001c310      853f           sta 0x3f
            0x0001c312      60             rts

vemos solo los puntos importantes, para lo que queremos lograr, documento un poco el código

veamos esta subrutina desde radare2.

     0x0001c2f9     a905   lda 0x05     ; A = 5
     0x0001c2fb     a424   ldy 0x24
                                        ; Valida el codigo konami.
┌──< 0x0001c2fd     f002   beq 0x01c301          
│    0x0001c2ff     a91d   lda 0x1d     ; A = 29 (Konami Code)
└──> 0x0001c301     9532   sta 0x32,x   ; [0x32]Vidas = A

El código anterior asigna la cantidad de vidas según se utilice o no el código Konami. En nuestra modificación previa, si no se usa el código Konami se comienza con 5 vidas, y si se usa, se inicia con 29. Es importante recordar que en el juego la vida 0 cuenta, por lo que tener 5 vidas equivale realmente a tener 6.

En nuestro caso, no nos interesa conservar la lógica del código Konami; en su lugar, la sustituiremos para asignar el arma con la que deseamos que el juego inicie. Esto se puede lograr de la siguiente manera.

Iniciar el juego con el arma Machine Gun Falcon, su valor es 1.

0x0001c2f9     a901   lda 0x01     ; A = 1
0x0001c2fb     a424   ldy 0x24
0x0001c2fd     95aa   sta 0xaa,x   ; [0xff]Arma = 1      
0x0001c2ff     a905   lda 0x05     ; A = 5
0x0001c301     9532   sta 0x32,x   ; [0x32]Vidas = A = 5

Como asignamos los cambios a nuestro juego?

primero asignamos el codigo de la dirección 0x0001c2f9, que asigna A = 1

wx a901 @ 0x0001c2f9

segundo modificamos el salto para que ahora guarde el valor de A en la dirección de memoria del arma.

wx 95aa @ 0x0001c2fd

tercero, asignamos el valor de las vidas a A de nuevo en la dirección 0x0001c2ff

wx a905 @ 0x0001c2ff

para validar los cambios podemos utilizar el siguiente comando

[0x00000000]> pd 6 @ 0x0001c2f7
            0x0001c2f7      8539           sta 0x39
            0x0001c2f9      a901           lda 0x01
            0x0001c2fb      a424           ldy 0x24
            0x0001c2fd      95aa           sta 0xaa,x
            0x0001c2ff      a905           lda 0x05
            0x0001c301      9532           sta 0x32,x

Como conservar el arma luego de la muerte?

Con lo aplicado en los puntos anteriores esto deberia ser bastante sencillo, lo unico que tenemos que hacer para encontrar la logica encargada de asignar el arma tras nuestra muerte es agregar un breakpoint de escritura en la dirección 0xAA desde el emulador ya que ahi es donde se registra el arma asignada, posteriormente nos dejamos matar y vemos la dirección que aparece en el debugger.

deberiamos ver lo siguiente.

07:DA07   LDA A, #$00     ; A = 0
07:DA09   STA $AA, X      ; [0xaa]Arma = 0 = Gun

Esta es la logica que asigna el arma cuando nos matan, la documentación de la derecha es agregada por mi como siempre, no es visible en el emulador, para saber cual es la dirección que tenemos que modificar en radare, ya que el emulador nos muestra que esta en el banco 7 y la dirección 0xda07, solo posicionamos el cursor sobre la dirección que queremos y anotamos el offset.

ahora vamos a radare.

[0x00000000]> pd 2 @ 0x1da17
            0x0001da17      a900           lda 0x00
            0x0001da19      95aa           sta 0xaa,x

podemos ver que en la dirección 0x1da17 que encontramos con el emulador, tenemos el codigo a modificar, solo tenemos que cambier el valor que se asigna, con el valor que nosotros deseamos de la siguiente manera.

Asignar el Laser Falcon cuando nos maten, su valor es 4, podemos escribirlo con el siguiente comando.

wx a904 @ 0x0001da17

ya sabemos como validarlo, de igual manera lo explico de nuevo, simplemente vamos a ver el código de la subrutina de nuevo.

[0x00000000]> pd 2 @ 0x1da17
            0x0001da17      a904           lda 0x04
            0x0001da19      95aa           sta 0xaa,x

Quieres intentarlo?

Pues te dejo algunas tareas para practicar lo aprendido.

  1. Asigna vidas infinitas.
  2. Asigna que todos los power up asignen el mismo arma.
  3. Haz que tu jugador nunca muera aunque reciba disparos.