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:
- Leer la entrada del control 1 bit a bit
- Guardar la salida en la posición de memoria 0x00
- Leer la entrada del control 2 bit a bit
- Guardar la salida en la posición de memoria 0x01
- 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:
- 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$4016y la señal de lectura del bus en alto). - Generación de la señal física: Luego la lógica interna del 2A03 conmuta a nivel bajo la línea de salida (
/JOY1o/JOY2). Con esto se genera un pulso de clock directo al pin 9 (CLK) del registro de desplazamiento 4021. - 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). - 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